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
28 changes: 27 additions & 1 deletion Sources/ContainerCommands/BuildCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,33 @@ extension Application {
buildFilePath = resolvedPath
}

let buildFileData = try Data(contentsOf: URL(filePath: buildFilePath))
let buildFileData: Data
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me.

Could you add a test for this? Just copy one of the simple tests in CLIBuilderTest and supply the Dockerfile via stdin instead of a file.

I think this will require creating a separate helper function to https://github.com/apple/container/blob/main/Tests/CLITests/Subcommands/Build/CLIBuildBase.swift#L112, since there's nothing in that test support stuff that knows how to pipe stdin into container build.

Copy link
Contributor Author

@saehejkang saehejkang Nov 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to make changes in the run function of the CLITest file as well. There needs to be a way to add the data to the stdin pipe.

let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("Dockerfile-\(UUID().uuidString)")
defer {
try? FileManager.default.removeItem(at: tempFile)
}

// Dockerfile should be read from stdin
if file == "-" {
guard FileManager.default.createFile(atPath: tempFile.path(), contents: nil) else {
throw ContainerizationError(.internalError, message: "unable to create temporary file")
}

guard let outputHandle = try? FileHandle(forWritingTo: tempFile) else {
throw ContainerizationError(.internalError, message: "unable to open temporary file for writing")
}

let bufferSize = 4096
while true {
let chunk = FileHandle.standardInput.readData(ofLength: bufferSize)
if chunk.isEmpty { break }
outputHandle.write(chunk)
}
try outputHandle.close()
buildFileData = try Data(contentsOf: URL(filePath: tempFile.path()))
} else {
buildFileData = try Data(contentsOf: URL(filePath: buildFilePath))
}

let systemHealth = try await ClientHealthCheck.ping(timeout: .seconds(10))
let exportPath = systemHealth.appRoot
Expand Down
42 changes: 39 additions & 3 deletions Tests/CLITests/Subcommands/Build/CLIBuildBase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,42 @@ class TestCLIBuildBase: CLITest {
return response.output
}

@discardableResult
func buildWithStdin(
tags: [String],
tempContext: URL,
dockerfileContents: String,
buildArgs: [String] = [],
otherArgs: [String] = []
) throws -> String {
let contextDir: URL = tempContext.appendingPathComponent("context")
let contextDirPath = contextDir.absoluteURL.path
var args = [
"build",
"-f",
"-",
]
for tag in tags {
args.append("-t")
args.append(tag)
}
for arg in buildArgs {
args.append("--build-arg")
args.append(arg)
}
args.append(contextDirPath)

args.append(contentsOf: otherArgs)

let stdinData = Data(dockerfileContents.utf8)
let response = try run(arguments: args, stdin: stdinData)
if response.status != 0 {
throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)")
}

return response.output
}

enum FileSystemEntry {
case file(
_ path: String,
Expand Down Expand Up @@ -252,7 +288,7 @@ class TestCLIBuildBase: CLITest {
}

func builderStart(cpus: Int64 = 2, memoryInGBs: Int64 = 2) throws {
let (_, error, status) = try run(arguments: [
let (_, _, error, status) = try run(arguments: [
"builder",
"start",
"-c",
Expand All @@ -266,7 +302,7 @@ class TestCLIBuildBase: CLITest {
}

func builderStop() throws {
let (_, error, status) = try run(arguments: [
let (_, _, error, status) = try run(arguments: [
"builder",
"stop",
])
Expand All @@ -276,7 +312,7 @@ class TestCLIBuildBase: CLITest {
}

func builderDelete(force: Bool = false) throws {
let (_, error, status) = try run(
let (_, _, error, status) = try run(
arguments: [
"builder",
"delete",
Expand Down
15 changes: 15 additions & 0 deletions Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -456,5 +456,20 @@ extension TestCLIBuildBase {
#expect(try self.inspectImage(tag2) == tag2, "expected to have successfully built \(tag2)")
#expect(try self.inspectImage(tag3) == tag3, "expected to have successfully built \(tag3)")
}

@Test func testBuildWithDockerfileFromStdin() throws {
let tempDir: URL = try createTempDir()
let dockerfile =
"""
FROM scratch

ADD emptyFile /
"""
let context: [FileSystemEntry] = [.file("emptyFile", content: .zeroFilled(size: 1))]
try createContext(tempDir: tempDir, dockerfile: "", context: context)
let imageName = "registry.local/stdin-file:\(UUID().uuidString)"
try buildWithStdin(tags: [imageName], tempContext: tempDir, dockerfileContents: dockerfile)
#expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)")
}
}
}
10 changes: 5 additions & 5 deletions Tests/CLITests/Subcommands/Images/TestCLIImages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class TestCLIImagesCommand: CLITest {
args.append("--all")
}

let (_, error, status) = try run(arguments: args)
let (_, _, error, status) = try run(arguments: args)
if status != 0 {
throw CLIError.executionFailed("command failed: \(error)")
}
Expand All @@ -49,7 +49,7 @@ class TestCLIImagesCommand: CLITest {
}

func doListImages() throws -> [Image] {
let (output, error, status) = try run(arguments: [
let (_, output, error, status) = try run(arguments: [
"image",
"list",
"--format",
Expand All @@ -75,7 +75,7 @@ class TestCLIImagesCommand: CLITest {
newName,
]

let (_, error, status) = try run(arguments: tagArgs)
let (_, _, error, status) = try run(arguments: tagArgs)
if status != 0 {
throw CLIError.executionFailed("command failed: \(error)")
}
Expand Down Expand Up @@ -323,7 +323,7 @@ extension TestCLIImagesCommand {
"--output",
tempFile.path(),
]
let (_, error, status) = try run(arguments: saveArgs)
let (_, _, error, status) = try run(arguments: saveArgs)
if status != 0 {
throw CLIError.executionFailed("command failed: \(error)")
}
Expand All @@ -344,7 +344,7 @@ extension TestCLIImagesCommand {
"-i",
tempFile.path(),
]
let (_, loadErr, loadStatus) = try run(arguments: loadArgs)
let (_, _, loadErr, loadStatus) = try run(arguments: loadArgs)
if loadStatus != 0 {
throw CLIError.executionFailed("command failed: \(loadErr)")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ struct TestCLIPluginErrors {
// without the APIServer started, so DefaultCommand will fail to create
// a PluginLoader and emit the improved guidance.
let cli = try CLITest()
let (_, stderr, status) = try cli.run(arguments: ["nosuchplugin"]) // non-existent plugin name
let (_, _, stderr, status) = try cli.run(arguments: ["nosuchplugin"]) // non-existent plugin name

#expect(status != 0)
#expect(stderr.contains("container system start"))
Expand Down
4 changes: 2 additions & 2 deletions Tests/CLITests/Subcommands/Run/TestCLIRunLifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ class TestCLIRunLifecycle: CLITest {
}
try self.waitForContainerRunning(name)

let (output, _, status) = try self.run(arguments: ["start", name])
let (_, output, _, status) = try self.run(arguments: ["start", name])
#expect(status == 0, "expected start to succeed on already running container")
#expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == name, "expected output to be container name")

Expand All @@ -76,7 +76,7 @@ class TestCLIRunLifecycle: CLITest {
}
try self.waitForContainerRunning(name)

let (_, error, status) = try self.run(arguments: ["start", "-a", name])
let (_, _, error, status) = try self.run(arguments: ["start", "-a", name])
#expect(status != 0, "expected start with attach to fail on already running container")
#expect(error.contains("attach is currently unsupported on already running containers"), "expected error message about attach not supported")

Expand Down
2 changes: 1 addition & 1 deletion Tests/CLITests/Subcommands/Run/TestCLIRunOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@ class TestCLIRunCommand: CLITest {
}

func getDefaultDomain() throws -> String? {
let (output, err, status) = try run(arguments: ["system", "property", "get", "dns.domain"])
let (_, output, err, status) = try run(arguments: ["system", "property", "get", "dns.domain"])
try #require(status == 0, "default DNS domain retrieval returned status \(status): \(err)")
let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedOutput == "" {
Expand Down
4 changes: 2 additions & 2 deletions Tests/CLITests/Subcommands/System/TestKernelSet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class TestCLIKernelSet: CLITest {
"--recommended",
"--force",
]
let (_, error, status) = try run(arguments: arguments)
let (_, _, error, status) = try run(arguments: arguments)
if status != 0 {
throw CLIError.executionFailed("failed to reset kernel to recommended: \(error)")
}
Expand All @@ -56,7 +56,7 @@ class TestCLIKernelSet: CLITest {
]
arguments.append(contentsOf: extraArgs)

let (_, error, status) = try run(arguments: arguments)
let (_, _, error, status) = try run(arguments: arguments)
if status != 0 {
throw CLIError.executionFailed("failed to set kernel: \(error)")
}
Expand Down
34 changes: 17 additions & 17 deletions Tests/CLITests/Subcommands/Volumes/TestCLIAnonymousVolumes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class TestCLIAnonymousVolumes: CLITest {

private func cleanupAllTestResources() {
// Clean up test containers (force remove)
if let (output, _, status) = try? run(arguments: ["ls", "-a"]), status == 0 {
if let (_, output, _, status) = try? run(arguments: ["ls", "-a"]), status == 0 {
let containers = output.components(separatedBy: .newlines)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { $0.lowercased().starts(with: "test") }
Expand All @@ -40,7 +40,7 @@ class TestCLIAnonymousVolumes: CLITest {
}

// Clean up test volumes (both anonymous and named)
if let (output, _, status) = try? run(arguments: ["volume", "list", "--quiet"]), status == 0 {
if let (_, output, _, status) = try? run(arguments: ["volume", "list", "--quiet"]), status == 0 {
let volumes = output.components(separatedBy: .newlines)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { isValidUUID($0) || $0.lowercased().starts(with: "test") }
Expand All @@ -56,7 +56,7 @@ class TestCLIAnonymousVolumes: CLITest {
}

func getAnonymousVolumeNames() throws -> [String] {
let (output, error, status) = try run(arguments: ["volume", "list", "--quiet"])
let (_, output, error, status) = try run(arguments: ["volume", "list", "--quiet"])
guard status == 0 else {
throw CLIError.executionFailed("volume list failed: \(error)")
}
Expand All @@ -66,7 +66,7 @@ class TestCLIAnonymousVolumes: CLITest {
}

func volumeExists(name: String) throws -> Bool {
let (output, _, status) = try run(arguments: ["volume", "list", "--quiet"])
let (_, output, _, status) = try run(arguments: ["volume", "list", "--quiet"])
guard status == 0 else { return false }
let volumes = output.components(separatedBy: .newlines)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
Expand All @@ -80,14 +80,14 @@ class TestCLIAnonymousVolumes: CLITest {
}

func doVolumeCreate(name: String) throws {
let (_, error, status) = try run(arguments: ["volume", "create", name])
let (_, _, error, status) = try run(arguments: ["volume", "create", name])
if status != 0 {
throw CLIError.executionFailed("volume create failed: \(error)")
}
}

func doVolumeDeleteIfExists(name: String) {
let (_, _, _) = (try? run(arguments: ["volume", "rm", name])) ?? ("", "", 1)
let (_, _, _, _) = (try? run(arguments: ["volume", "rm", name])) ?? (nil, "", "", 1)
}

func doRemoveIfExists(name: String, force: Bool = false) {
Expand All @@ -96,7 +96,7 @@ class TestCLIAnonymousVolumes: CLITest {
args.append("--force")
}
args.append(name)
let (_, _, _) = (try? run(arguments: args)) ?? ("", "", 1)
let (_, _, _, _) = (try? run(arguments: args)) ?? (nil, "", "", 1)
}

@Test func testAnonymousVolumeCreationAndPersistence() async throws {
Expand All @@ -115,7 +115,7 @@ class TestCLIAnonymousVolumes: CLITest {
let beforeCount = try getAnonymousVolumeNames().count

// Run container with --rm and anonymous volume
let (_, _, status) = try run(arguments: [
let (_, _, _, status) = try run(arguments: [
"run",
"--rm",
"--name",
Expand All @@ -133,7 +133,7 @@ class TestCLIAnonymousVolumes: CLITest {
try await Task.sleep(for: .seconds(1))

// Verify container was removed
let (lsOutput, _, _) = try run(arguments: ["ls", "-a"])
let (_, lsOutput, _, _) = try run(arguments: ["ls", "-a"])
let containers = lsOutput.components(separatedBy: .newlines)
.filter { $0.contains(containerName) }
#expect(containers.isEmpty, "container should be removed with --rm")
Expand Down Expand Up @@ -206,7 +206,7 @@ class TestCLIAnonymousVolumes: CLITest {
let beforeCount = try getAnonymousVolumeNames().count

// Run with multiple anonymous volumes
let (_, _, status) = try run(arguments: [
let (_, _, _, status) = try run(arguments: [
"run",
"--rm",
"--name",
Expand Down Expand Up @@ -243,7 +243,7 @@ class TestCLIAnonymousVolumes: CLITest {
let beforeCount = try getAnonymousVolumeNames().count

// Use --mount syntax
let (_, _, status) = try run(arguments: [
let (_, _, _, status) = try run(arguments: [
"run",
"--rm",
"--name",
Expand Down Expand Up @@ -314,7 +314,7 @@ class TestCLIAnonymousVolumes: CLITest {
let volumeName = volumeNames[0]

// Inspect volume in JSON format
let (output, error, status) = try run(arguments: ["volume", "list", "--format", "json"])
let (_, output, error, status) = try run(arguments: ["volume", "list", "--format", "json"])
#expect(status == 0, "volume list should succeed: \(error)")

// Parse JSON to verify metadata
Expand Down Expand Up @@ -351,7 +351,7 @@ class TestCLIAnonymousVolumes: CLITest {
try waitForContainerRunning(containerName)

// List volumes
let (output, error, status) = try run(arguments: ["volume", "list"])
let (_, output, error, status) = try run(arguments: ["volume", "list"])
#expect(status == 0, "volume list should succeed: \(error)")

// Verify TYPE column exists and shows both types
Expand Down Expand Up @@ -381,7 +381,7 @@ class TestCLIAnonymousVolumes: CLITest {
let beforeAnonCount = try getAnonymousVolumeNames().count

// Run with both named and anonymous volumes, with --rm
let (_, _, status) = try run(arguments: [
let (_, _, _, status) = try run(arguments: [
"run",
"--rm",
"--name",
Expand Down Expand Up @@ -427,7 +427,7 @@ class TestCLIAnonymousVolumes: CLITest {
doRemoveIfExists(name: containerName, force: true)

// Manual deletion should succeed (volume is unmounted)
let (_, error, status) = try run(arguments: ["volume", "rm", volumeID])
let (_, _, error, status) = try run(arguments: ["volume", "rm", volumeID])
#expect(status == 0, "manual deletion of unmounted anonymous volume should succeed: \(error)")

// Verify volume is gone
Expand All @@ -450,7 +450,7 @@ class TestCLIAnonymousVolumes: CLITest {
let beforeCount = try getAnonymousVolumeNames().count

// Run in detached mode with --rm
let (_, _, status) = try run(arguments: [
let (_, _, _, status) = try run(arguments: [
"run",
"-d",
"--rm",
Expand All @@ -467,7 +467,7 @@ class TestCLIAnonymousVolumes: CLITest {
try await Task.sleep(for: .seconds(3))

// Container should be removed
let (lsOutput, _, _) = try run(arguments: ["ls", "-a"])
let (_, lsOutput, _, _) = try run(arguments: ["ls", "-a"])
let containers = lsOutput.components(separatedBy: .newlines)
.filter { $0.contains(containerName) }
#expect(containers.isEmpty, "container should be auto-removed")
Expand Down
Loading