diff --git a/Package.swift b/Package.swift index d25dac7f..f446ba86 100644 --- a/Package.swift +++ b/Package.swift @@ -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"), ] ), diff --git a/Sources/Integration/CctlRunTests.swift b/Sources/Integration/CctlRunTests.swift new file mode 100644 index 00000000..f63b3b11 --- /dev/null +++ b/Sources/Integration/CctlRunTests.swift @@ -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 diff --git a/Sources/Integration/IntegrationSuite+CctlCLI.swift b/Sources/Integration/IntegrationSuite+CctlCLI.swift new file mode 100644 index 00000000..3de888d5 --- /dev/null +++ b/Sources/Integration/IntegrationSuite+CctlCLI.swift @@ -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.. (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.. [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) diff --git a/Sources/cctl/cctl+Utils.swift b/Sources/cctl/cctl+Utils.swift index 1b70ab6b..21bcda27 100644 --- a/Sources/cctl/cctl+Utils.swift +++ b/Sources/cctl/cctl+Utils.swift @@ -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 { diff --git a/Tests/cctlTests/RunArgumentsTests.swift b/Tests/cctlTests/RunArgumentsTests.swift new file mode 100644 index 00000000..579da710 --- /dev/null +++ b/Tests/cctlTests/RunArgumentsTests.swift @@ -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")) + } + } +}