mirror of
https://github.com/apple/container.git
synced 2026-10-24 23:58:42 +00:00
Update cctl to respect entrypoint and cmd (#906)
This expands `cctl run` to utilize entrypoint and cmd when present in the OCI image, instead of always defaulting to `/bin/sh`. The user can still provide explicit commands if necessary to override these values.
This commit is contained in:
1 parent
fc9e63846f
commit
d7fc7c15a2
8 files changed
+555
-66
No files matched your search
@@ -211,9 +211,6 @@ let package = Package(
|
||||
.product(name: "NIOSSL", package: "swift-nio-ssl"),
|
||||
.product(name: "X509", package: "swift-certificates"),
|
||||
.product(name: "NIOHTTP1", package: "swift-nio"),
|
||||
.product(name: "NIOSSL", package: "swift-nio-ssl"),
|
||||
.product(name: "X509", package: "swift-certificates"),
|
||||
.product(name: "NIOHTTP1", package: "swift-nio"),
|
||||
.product(name: "Crypto", package: "swift-crypto"),
|
||||
]
|
||||
),
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
#if os(macOS)
|
||||
extension IntegrationSuite {
|
||||
|
||||
/// Image used by the `cctl run` command-resolution tests. It declares no
|
||||
/// ENTRYPOINT and `CMD ["/bin/sh"]`, which is what makes the default-command
|
||||
/// case observable.
|
||||
static let cctlRunImage = "docker.io/library/alpine:3.16"
|
||||
|
||||
/// With no trailing command, `cctl run` runs the image's ENTRYPOINT + CMD.
|
||||
///
|
||||
/// The alpine test image declares no ENTRYPOINT and `CMD ["/bin/sh"]`, so a
|
||||
/// bare `cctl run` should land in a shell. Feeding a command on stdin proves
|
||||
/// it really is a shell rather than something that merely exited cleanly.
|
||||
func testCctlRunUsesImageDefaultCommand() async throws {
|
||||
let marker = "DEFAULT_CMD_RAN"
|
||||
let (output, exitCode) = try runCctl(
|
||||
arguments: [
|
||||
"run", "--kernel", self.kernel, "--id", "test-cctl-run-default-cmd",
|
||||
"-i", Self.cctlRunImage,
|
||||
],
|
||||
input: "echo \(marker)\nexit\n"
|
||||
)
|
||||
|
||||
guard exitCode == 0 else {
|
||||
throw IntegrationError.assert(msg: "cctl run exited with \(exitCode): \(output)")
|
||||
}
|
||||
guard output.contains(marker) else {
|
||||
throw IntegrationError.assert(msg: "expected the image's CMD shell to run, got: \(output)")
|
||||
}
|
||||
}
|
||||
|
||||
/// A trailing command replaces the image's CMD.
|
||||
func testCctlRunExplicitCommandOverridesDefault() async throws {
|
||||
let marker = "EXPLICIT_COMMAND_RAN"
|
||||
let (output, exitCode) = try runCctl(arguments: [
|
||||
"run", "--kernel", self.kernel, "--id", "test-cctl-run-explicit-command",
|
||||
"-i", Self.cctlRunImage,
|
||||
"/bin/echo", marker,
|
||||
])
|
||||
|
||||
guard exitCode == 0 else {
|
||||
throw IntegrationError.assert(msg: "cctl run exited with \(exitCode): \(output)")
|
||||
}
|
||||
guard output.contains(marker) else {
|
||||
throw IntegrationError.assert(msg: "expected the explicit command to run, got: \(output)")
|
||||
}
|
||||
}
|
||||
|
||||
/// An image declaring neither ENTRYPOINT nor CMD has nothing to run, so a
|
||||
/// bare `cctl run` must say so rather than falling back to a hardcoded
|
||||
/// shell. `vminit:latest` is built by `cctl rootfs create`, which sets only
|
||||
/// labels on the image config — no command of any kind.
|
||||
///
|
||||
/// This is the case that cannot pass under the old behaviour, where the
|
||||
/// command defaulted to `/bin/sh` regardless of the image.
|
||||
func testCctlRunWithoutEntrypointOrCmdFails() async throws {
|
||||
let (output, exitCode) = try runCctl(arguments: [
|
||||
"run", "--kernel", self.kernel, "--id", "test-cctl-run-no-command",
|
||||
"-i", Self.initImage,
|
||||
])
|
||||
|
||||
guard exitCode != 0 else {
|
||||
throw IntegrationError.assert(msg: "expected a failure for an image with no entrypoint or cmd: \(output)")
|
||||
}
|
||||
guard output.contains("declares no entrypoint or cmd") else {
|
||||
throw IntegrationError.assert(msg: "expected an entrypoint/cmd error, got: \(output)")
|
||||
}
|
||||
}
|
||||
|
||||
/// `--entrypoint` replaces the image's ENTRYPOINT while leaving its CMD in
|
||||
/// place. The alpine test image has no ENTRYPOINT and `CMD ["/bin/sh"]`, so
|
||||
/// overriding with `/bin/echo` and passing no command must print the CMD
|
||||
/// rather than start a shell — which is what makes the appending observable.
|
||||
func testCctlRunEntrypointOverrideKeepsImageCmd() async throws {
|
||||
let (output, exitCode) = try runCctl(arguments: [
|
||||
"run", "--kernel", self.kernel, "--id", "test-cctl-run-entrypoint",
|
||||
"-i", Self.cctlRunImage,
|
||||
"--entrypoint", "/bin/echo",
|
||||
])
|
||||
|
||||
guard exitCode == 0 else {
|
||||
throw IntegrationError.assert(msg: "cctl run exited with \(exitCode): \(output)")
|
||||
}
|
||||
guard output.contains("/bin/sh") else {
|
||||
throw IntegrationError.assert(msg: "expected the image's cmd to be appended to the override, got: \(output)")
|
||||
}
|
||||
}
|
||||
|
||||
/// An override plus a trailing command: the override supplies the executable
|
||||
/// and the command replaces the image's CMD.
|
||||
func testCctlRunEntrypointOverrideWithCommand() async throws {
|
||||
let marker = "ENTRYPOINT_OVERRIDE_RAN"
|
||||
let (output, exitCode) = try runCctl(arguments: [
|
||||
"run", "--kernel", self.kernel, "--id", "test-cctl-run-entrypoint-command",
|
||||
"-i", Self.cctlRunImage,
|
||||
"--entrypoint", "/bin/echo", marker,
|
||||
])
|
||||
|
||||
guard exitCode == 0 else {
|
||||
throw IntegrationError.assert(msg: "cctl run exited with \(exitCode): \(output)")
|
||||
}
|
||||
guard output.contains(marker) else {
|
||||
throw IntegrationError.assert(msg: "expected the override to run with the trailing command, got: \(output)")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,89 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
import struct ContainerizationOS.Terminal
|
||||
|
||||
#if os(macOS)
|
||||
extension IntegrationSuite {
|
||||
|
||||
/// Run `bin/cctl run` to completion and return its combined stdout+stderr.
|
||||
///
|
||||
/// Exercises the `cctl` CLI rather than the library directly, so it covers
|
||||
/// argument parsing and the CLI's own wiring end to end. Shared with the
|
||||
/// `--block` tests in PodVolumeTests. `bin/cctl` must be built and
|
||||
/// codesigned with the virtualization entitlement.
|
||||
///
|
||||
/// - Parameter input: written to the container's stdin, for commands that
|
||||
/// read from it. The pty stays open until the child exits either way.
|
||||
func runCctl(arguments: [String], input: String? = nil) throws -> (output: String, exitCode: Int32) {
|
||||
let cctl = Self.binPath(name: "cctl")
|
||||
guard FileManager.default.isExecutableFile(atPath: cctl.path) else {
|
||||
throw IntegrationError.assert(msg: "bin/cctl not built or not executable at \(cctl.path); run `make containerization`")
|
||||
}
|
||||
|
||||
let (parent, child) = try Terminal.create()
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = cctl
|
||||
process.arguments = arguments
|
||||
// `cctl run` resolves relative paths (kernel, bin/) against the cwd.
|
||||
process.currentDirectoryURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
||||
process.standardInput = child.handle
|
||||
process.standardOutput = child.handle
|
||||
process.standardError = child.handle
|
||||
|
||||
try process.run()
|
||||
// Drop our copy of the child fd. While any process here holds it open
|
||||
// the parent side never reports EOF and the drain below never returns.
|
||||
try child.close()
|
||||
|
||||
if let input {
|
||||
// Written to the pty parent, which the child reads as stdin. Ignore
|
||||
// a short write: the drain below reports whatever the child did.
|
||||
_ = try? parent.handle.write(contentsOf: Data(input.utf8))
|
||||
}
|
||||
|
||||
// Drain before waiting: the pty buffer is far smaller than a pipe's, so
|
||||
// a chatty container blocks quickly.
|
||||
var data = Data()
|
||||
var buf = [UInt8](repeating: 0, count: 4096)
|
||||
let parentFD = parent.handle.fileDescriptor
|
||||
while true {
|
||||
let n = read(parentFD, &buf, buf.count)
|
||||
if n > 0 {
|
||||
data.append(contentsOf: buf[0..<n])
|
||||
continue
|
||||
}
|
||||
// n == 0 is EOF. n < 0 with EIO is how Darwin reports "last child
|
||||
// fd closed" on a pty parent, which is EOF for us too. Retry EINTR.
|
||||
if n < 0 && errno == EINTR {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
process.waitUntilExit()
|
||||
try? parent.close()
|
||||
|
||||
// The pty applies ONLCR until cctl switches it to raw mode, so the
|
||||
// output is a mix of "\r\n" and "\n". Normalize for callers' matching.
|
||||
let output = (String(data: data, encoding: .utf8) ?? "")
|
||||
.replacingOccurrences(of: "\r\n", with: "\n")
|
||||
return (output, process.terminationStatus)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -79,63 +79,6 @@ extension IntegrationSuite {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `bin/cctl run` to completion and return its combined stdout+stderr.
|
||||
///
|
||||
/// This exercises the `cctl` CLI rather than the
|
||||
/// library directly, so it validates the `--block` option wiring end to end:
|
||||
/// argument parsing, `Mount` construction, VZ attachment, and the guest
|
||||
/// mount. `bin/cctl` is built and codesigned with the virtualization
|
||||
/// entitlement.
|
||||
private func runCctl(arguments: [String]) throws -> (output: String, exitCode: Int32) {
|
||||
let cctl = Self.binPath(name: "cctl")
|
||||
guard FileManager.default.isExecutableFile(atPath: cctl.path) else {
|
||||
throw IntegrationError.assert(msg: "bin/cctl not built or not executable at \(cctl.path); run `make containerization`")
|
||||
}
|
||||
|
||||
let (parent, child) = try Terminal.create()
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = cctl
|
||||
process.arguments = arguments
|
||||
// `cctl run` resolves relative paths (kernel, bin/) against the cwd.
|
||||
process.currentDirectoryURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
||||
process.standardInput = child.handle
|
||||
process.standardOutput = child.handle
|
||||
process.standardError = child.handle
|
||||
|
||||
try process.run()
|
||||
// Drop our copy of the child fd. While any process here holds it open
|
||||
// the parent side never reports EOF and the drain below never returns.
|
||||
try child.close()
|
||||
|
||||
// Drain before waiting: the pty buffer is far smaller than a pipe's, so
|
||||
// a chatty container blocks quickly.
|
||||
var data = Data()
|
||||
var buf = [UInt8](repeating: 0, count: 4096)
|
||||
let parentFD = parent.handle.fileDescriptor
|
||||
while true {
|
||||
let n = read(parentFD, &buf, buf.count)
|
||||
if n > 0 {
|
||||
data.append(contentsOf: buf[0..<n])
|
||||
continue
|
||||
}
|
||||
// n == 0 is EOF. n < 0 with EIO is how Darwin reports "last child
|
||||
// fd closed" on a pty parent, which is EOF for us too. Retry EINTR.
|
||||
if n < 0 && errno == EINTR {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
process.waitUntilExit()
|
||||
try? parent.close()
|
||||
|
||||
// The pty applies ONLCR until cctl switches it to raw mode, so the
|
||||
// output is a mix of "\r\n" and "\n". Normalize for callers' matching.
|
||||
let output = (String(data: data, encoding: .utf8) ?? "")
|
||||
.replacingOccurrences(of: "\r\n", with: "\n")
|
||||
return (output, process.terminationStatus)
|
||||
}
|
||||
|
||||
func testContainerNBDMount() async throws {
|
||||
let id = "test-container-nbd-mount"
|
||||
let bs = try await bootstrap(id)
|
||||
|
||||
@@ -623,11 +623,18 @@ struct IntegrationSuite: AsyncParsableCommand {
|
||||
Test("pod shared disk image volume", testPodSharedDiskImageVolume),
|
||||
Test("pod shared tmpfs volume", testPodSharedTmpfsVolume),
|
||||
|
||||
// cctl --block CLI wiring (spawns bin/cctl; skips if unbuilt)
|
||||
// cctl --block CLI wiring
|
||||
Test("cctl block NBD mount", testCctlBlockNBDMount),
|
||||
Test("cctl block NBD raw", testCctlBlockNBDRaw),
|
||||
Test("cctl block NBD format and persist", testCctlBlockNBDFormatAndPersist),
|
||||
Test("cctl block rejects invalid spec", testCctlBlockRejectsInvalidSpec),
|
||||
|
||||
// cctl run command entrypoint resolution
|
||||
Test("cctl run uses image default command", testCctlRunUsesImageDefaultCommand),
|
||||
Test("cctl run explicit command overrides default", testCctlRunExplicitCommandOverridesDefault),
|
||||
Test("cctl run without entrypoint or cmd fails", testCctlRunWithoutEntrypointOrCmdFails),
|
||||
Test("cctl run entrypoint override keeps image cmd", testCctlRunEntrypointOverrideKeepsImageCmd),
|
||||
Test("cctl run entrypoint override with command", testCctlRunEntrypointOverrideWithCommand),
|
||||
] + macOS26Tests()
|
||||
let tests: [Test] = crossPlatformTests + macOSOnlyTests
|
||||
#else
|
||||
|
||||
@@ -99,8 +99,20 @@ extension Application {
|
||||
@Option(name: .long, help: "Current working directory")
|
||||
var cwd: String = "/"
|
||||
|
||||
@Option(
|
||||
name: .customLong("entrypoint"),
|
||||
help: """
|
||||
Override the image's ENTRYPOINT with a single executable \
|
||||
(Example: --entrypoint /bin/ls). The image's CMD is still \
|
||||
appended; pass a trailing command to replace it.
|
||||
"""
|
||||
)
|
||||
var entrypointOverride: String?
|
||||
|
||||
/// Command to run in the container. When omitted the image's
|
||||
/// ENTRYPOINT + CMD is used.
|
||||
@Argument(parsing: .captureForPassthrough)
|
||||
var arguments: [String] = ["/bin/sh"]
|
||||
var arguments: [String] = []
|
||||
|
||||
func run() async throws {
|
||||
let kernel = Kernel(
|
||||
@@ -124,13 +136,22 @@ extension Application {
|
||||
)
|
||||
let sigwinchStream = AsyncSignalHandler.create(notify: [SIGWINCH])
|
||||
|
||||
// Get the actual image so we can parse out the ENTRYPOINT
|
||||
let image = try await manager.imageStore.get(reference: imageReference, pull: true)
|
||||
let processArguments = try await Self.resolveArguments(
|
||||
arguments,
|
||||
entrypointOverride: entrypointOverride,
|
||||
image: image,
|
||||
imageReference: imageReference
|
||||
)
|
||||
|
||||
let current = try Terminal.current
|
||||
try current.setraw()
|
||||
defer { current.tryReset() }
|
||||
|
||||
let container = try await manager.create(
|
||||
id,
|
||||
reference: imageReference,
|
||||
image: image,
|
||||
rootfsSizeInBytes: fsSizeInMB.mib(),
|
||||
readOnly: readOnly,
|
||||
networking: true
|
||||
@@ -138,7 +159,7 @@ extension Application {
|
||||
config.cpus = cpus
|
||||
config.memoryInBytes = memory.mib()
|
||||
config.process.setTerminalIO(terminal: current)
|
||||
config.process.arguments = arguments
|
||||
config.process.arguments = processArguments
|
||||
config.process.workingDirectory = cwd
|
||||
|
||||
for mount in self.mounts {
|
||||
@@ -245,6 +266,25 @@ extension Application {
|
||||
).first!
|
||||
.appendingPathComponent("com.apple.containerization")
|
||||
}()
|
||||
|
||||
/// Resolve the command from the image config.
|
||||
static func resolveArguments(
|
||||
_ arguments: [String],
|
||||
entrypointOverride: String?,
|
||||
image: Containerization.Image,
|
||||
imageReference: String
|
||||
) async throws -> [String] {
|
||||
// We hardcode linux arm64 for the kernel above, then proceed to use Platform.current in
|
||||
// ContainerManager.create. This means it will always default have to be arm64, so we
|
||||
// read that config directly.
|
||||
let imageConfig = try await image.config(for: .arm64).config
|
||||
return try Application.resolveProcessArguments(
|
||||
arguments: arguments,
|
||||
entrypointOverride: entrypointOverride,
|
||||
imageConfig: imageConfig,
|
||||
imageReference: imageReference
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -385,8 +425,20 @@ extension Application {
|
||||
@Option(name: .long, help: "Current working directory")
|
||||
var cwd: String = "/"
|
||||
|
||||
@Option(
|
||||
name: .customLong("entrypoint"),
|
||||
help: """
|
||||
Override the image's ENTRYPOINT with a single executable \
|
||||
(Example: --entrypoint /bin/ls). The image's CMD is still \
|
||||
appended; pass a trailing command to replace it.
|
||||
"""
|
||||
)
|
||||
var entrypointOverride: String?
|
||||
|
||||
/// Command to run in the container. When omitted the image's
|
||||
/// ENTRYPOINT + CMD is used.
|
||||
@Argument(parsing: .captureForPassthrough)
|
||||
var arguments: [String] = ["/bin/sh"]
|
||||
var arguments: [String] = []
|
||||
|
||||
func run() async throws {
|
||||
#if arch(arm64)
|
||||
@@ -471,7 +523,12 @@ extension Application {
|
||||
if let imageConfig {
|
||||
processConfig = .init(from: imageConfig)
|
||||
}
|
||||
processConfig.arguments = arguments
|
||||
processConfig.arguments = try Application.resolveProcessArguments(
|
||||
arguments: arguments,
|
||||
entrypointOverride: entrypointOverride,
|
||||
imageConfig: imageConfig,
|
||||
imageReference: imageReference
|
||||
)
|
||||
processConfig.workingDirectory = cwd
|
||||
if let hostTerminal {
|
||||
processConfig.setTerminalIO(terminal: hostTerminal)
|
||||
|
||||
@@ -45,6 +45,49 @@ extension Application {
|
||||
}
|
||||
return parsedLabels
|
||||
}
|
||||
|
||||
/// Parse the ENTRYPOINT and CMD from an `ImageConfig` into the command that
|
||||
/// is run.
|
||||
///
|
||||
/// If arguments are present, they replace CMD and are appended to ENTRYPOINT
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - arguments: trailing command; replaces CMD when non-empty.
|
||||
/// - entrypointOverride: single executable replacing ENTRYPOINT. CMD is
|
||||
/// still appended, so an override alone does not change the arguments
|
||||
/// the process receives.
|
||||
/// - imageConfig: source of ENTRYPOINT and CMD. Nil declares neither.
|
||||
/// - imageReference: names the image in the error.
|
||||
/// - Throws: when the image declares neither and no command was given, or
|
||||
/// when the override is empty.
|
||||
static func resolveProcessArguments(
|
||||
arguments: [String],
|
||||
entrypointOverride: String?,
|
||||
imageConfig: ImageConfig?,
|
||||
imageReference: String
|
||||
) throws -> [String] {
|
||||
guard entrypointOverride != "" else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "--entrypoint requires a non-empty path; omit the flag to use the image's entrypoint"
|
||||
)
|
||||
}
|
||||
|
||||
let imageEntrypoint = imageConfig?.entrypoint ?? []
|
||||
let imageCommand = imageConfig?.cmd ?? []
|
||||
|
||||
let entrypoint = entrypointOverride.map { [$0] } ?? imageEntrypoint
|
||||
let command = arguments.isEmpty ? imageCommand : arguments
|
||||
|
||||
let resolved = entrypoint + command
|
||||
guard !resolved.isEmpty else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "image \(imageReference) declares no entrypoint or cmd; pass a command to run"
|
||||
)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
|
||||
extension Containerization.Mount {
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// 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 ContainerizationOCI
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import cctl
|
||||
|
||||
/// `cctl run` resolves the container command as `ENTRYPOINT + (arguments ?? CMD)`.
|
||||
/// Trailing arguments replace CMD but are appended to ENTRYPOINT
|
||||
@Suite("cctl run command resolution")
|
||||
struct RunArgumentsTests {
|
||||
|
||||
/// One scenario. A nil `expected` means the resolution must fail.
|
||||
struct Case: Sendable, CustomTestStringConvertible {
|
||||
let name: String
|
||||
let imageConfig: ImageConfig?
|
||||
let arguments: [String]
|
||||
var entrypointOverride: String? = nil
|
||||
let expected: [String]?
|
||||
|
||||
var testDescription: String { name }
|
||||
}
|
||||
|
||||
private static let cases: [Case] = [
|
||||
// Entrypoint and cmd both present.
|
||||
.init(
|
||||
name: "no arguments uses entrypoint and cmd",
|
||||
imageConfig: ImageConfig(entrypoint: ["/bin/echo"], cmd: ["default"]),
|
||||
arguments: [],
|
||||
expected: ["/bin/echo", "default"]
|
||||
),
|
||||
.init(
|
||||
name: "arguments replace cmd but keep entrypoint",
|
||||
imageConfig: ImageConfig(entrypoint: ["/bin/echo"], cmd: ["default"]),
|
||||
arguments: ["override"],
|
||||
expected: ["/bin/echo", "override"]
|
||||
),
|
||||
// The regression that motivated this rule: replacing the whole command left
|
||||
// the guest trying to exec `--nbd-url`.
|
||||
.init(
|
||||
name: "flag-shaped arguments append to entrypoint",
|
||||
imageConfig: ImageConfig(entrypoint: ["/usr/local/bin/format-volume"], cmd: ["default"]),
|
||||
arguments: ["--nbd-url", "nbd://h:1/v", "--filesystem", "xfs"],
|
||||
expected: ["/usr/local/bin/format-volume", "--nbd-url", "nbd://h:1/v", "--filesystem", "xfs"]
|
||||
),
|
||||
.init(
|
||||
name: "multi-word entrypoint is preserved in order",
|
||||
imageConfig: ImageConfig(entrypoint: ["/bin/sh", "-c"], cmd: ["y"]),
|
||||
arguments: ["x"],
|
||||
expected: ["/bin/sh", "-c", "x"]
|
||||
),
|
||||
|
||||
// Only one of entrypoint / cmd.
|
||||
.init(
|
||||
name: "entrypoint only with no arguments",
|
||||
imageConfig: ImageConfig(entrypoint: ["/bin/true"]),
|
||||
arguments: [],
|
||||
expected: ["/bin/true"]
|
||||
),
|
||||
.init(
|
||||
name: "entrypoint only with arguments",
|
||||
imageConfig: ImageConfig(entrypoint: ["/bin/true"]),
|
||||
arguments: ["--flag"],
|
||||
expected: ["/bin/true", "--flag"]
|
||||
),
|
||||
.init(
|
||||
name: "cmd only with no arguments",
|
||||
imageConfig: ImageConfig(cmd: ["/bin/sh"]),
|
||||
arguments: [],
|
||||
expected: ["/bin/sh"]
|
||||
),
|
||||
// With no entrypoint the arguments are the whole command, so CMD is dropped.
|
||||
.init(
|
||||
name: "cmd only with arguments replaces cmd entirely",
|
||||
imageConfig: ImageConfig(cmd: ["/bin/sh"]),
|
||||
arguments: ["/bin/echo", "hi"],
|
||||
expected: ["/bin/echo", "hi"]
|
||||
),
|
||||
|
||||
// Nothing declared by the image, but the caller supplied the command outright.
|
||||
.init(
|
||||
name: "no entrypoint or cmd but arguments given",
|
||||
imageConfig: ImageConfig(),
|
||||
arguments: ["/bin/echo", "hi"],
|
||||
expected: ["/bin/echo", "hi"]
|
||||
),
|
||||
.init(
|
||||
name: "missing image config with arguments",
|
||||
imageConfig: nil,
|
||||
arguments: ["/bin/echo"],
|
||||
expected: ["/bin/echo"]
|
||||
),
|
||||
// Explicitly empty arrays must behave exactly like absent ones.
|
||||
.init(
|
||||
name: "empty arrays with arguments behave as absent",
|
||||
imageConfig: ImageConfig(entrypoint: [], cmd: []),
|
||||
arguments: ["/bin/echo"],
|
||||
expected: ["/bin/echo"]
|
||||
),
|
||||
|
||||
// Nothing to run.
|
||||
.init(
|
||||
name: "no entrypoint, no cmd, no arguments fails",
|
||||
imageConfig: ImageConfig(),
|
||||
arguments: [],
|
||||
expected: nil
|
||||
),
|
||||
.init(
|
||||
name: "missing image config and no arguments fails",
|
||||
imageConfig: nil,
|
||||
arguments: [],
|
||||
expected: nil
|
||||
),
|
||||
.init(
|
||||
name: "empty arrays with no arguments fails",
|
||||
imageConfig: ImageConfig(entrypoint: [], cmd: []),
|
||||
arguments: [],
|
||||
expected: nil
|
||||
),
|
||||
|
||||
// --entrypoint replaces the image's ENTRYPOINT. CMD is still appended,
|
||||
// so an override alone does not change the arguments the process gets.
|
||||
.init(
|
||||
name: "override replaces entrypoint and keeps cmd",
|
||||
imageConfig: ImageConfig(entrypoint: ["/app"], cmd: ["--serve"]),
|
||||
arguments: [],
|
||||
entrypointOverride: "/bin/ls",
|
||||
expected: ["/bin/ls", "--serve"]
|
||||
),
|
||||
.init(
|
||||
name: "override with trailing arguments replaces cmd",
|
||||
imageConfig: ImageConfig(entrypoint: ["/app"], cmd: ["--serve"]),
|
||||
arguments: ["-l"],
|
||||
entrypointOverride: "/bin/ls",
|
||||
expected: ["/bin/ls", "-l"]
|
||||
),
|
||||
// A multi-word entrypoint has to come through as override + arguments,
|
||||
// since the override itself is a single executable.
|
||||
.init(
|
||||
name: "override supplies the executable and arguments the rest",
|
||||
imageConfig: ImageConfig(entrypoint: ["/app"], cmd: ["--serve"]),
|
||||
arguments: ["foo.py"],
|
||||
entrypointOverride: "python3",
|
||||
expected: ["python3", "foo.py"]
|
||||
),
|
||||
// An override supplies a command even when the image declares nothing,
|
||||
// so this case can no longer fail.
|
||||
.init(
|
||||
name: "override on an image with no entrypoint or cmd",
|
||||
imageConfig: ImageConfig(),
|
||||
arguments: [],
|
||||
entrypointOverride: "/bin/true",
|
||||
expected: ["/bin/true"]
|
||||
),
|
||||
.init(
|
||||
name: "override on a cmd-only image",
|
||||
imageConfig: ImageConfig(cmd: ["/bin/sh"]),
|
||||
arguments: [],
|
||||
entrypointOverride: "/usr/bin/strace",
|
||||
expected: ["/usr/bin/strace", "/bin/sh"]
|
||||
),
|
||||
.init(
|
||||
name: "override with a missing image config",
|
||||
imageConfig: nil,
|
||||
arguments: [],
|
||||
entrypointOverride: "/bin/true",
|
||||
expected: ["/bin/true"]
|
||||
),
|
||||
|
||||
// An empty override would exec "" in the guest.
|
||||
.init(
|
||||
name: "empty override fails",
|
||||
imageConfig: ImageConfig(entrypoint: ["/app"], cmd: ["--serve"]),
|
||||
arguments: [],
|
||||
entrypointOverride: "",
|
||||
expected: nil
|
||||
),
|
||||
]
|
||||
|
||||
@Test("resolves", arguments: cases)
|
||||
func resolves(_ testCase: Case) throws {
|
||||
let resolve = {
|
||||
try Application.resolveProcessArguments(
|
||||
arguments: testCase.arguments,
|
||||
entrypointOverride: testCase.entrypointOverride,
|
||||
imageConfig: testCase.imageConfig,
|
||||
imageReference: "test:latest"
|
||||
)
|
||||
}
|
||||
|
||||
guard let expected = testCase.expected else {
|
||||
#expect(throws: (any Error).self) { try resolve() }
|
||||
return
|
||||
}
|
||||
|
||||
let resolved = try resolve()
|
||||
#expect(resolved == expected)
|
||||
}
|
||||
|
||||
@Test func errorNamesTheOffendingImage() {
|
||||
do {
|
||||
_ = try Application.resolveProcessArguments(
|
||||
arguments: [],
|
||||
entrypointOverride: nil,
|
||||
imageConfig: ImageConfig(),
|
||||
imageReference: "registry.example/thing:v2"
|
||||
)
|
||||
Issue.record("expected a failure for an image with no entrypoint or cmd")
|
||||
} catch {
|
||||
#expect("\(error)".contains("registry.example/thing:v2"))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user