[Build] fixes for tar output mode (#353)

Fixes https://github.com/apple/container/issues/347
This commit is contained in:
Sidhartha Mani
2025-07-18 23:50:11 +00:00
committed by GitHub
parent 1de3036f9a
commit f68919c137
5 changed files with 154 additions and 139 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ import PackageDescription
let releaseVersion = ProcessInfo.processInfo.environment["RELEASE_VERSION"] ?? "0.0.0"
let gitCommit = ProcessInfo.processInfo.environment["GIT_COMMIT"] ?? "unspecified"
let scVersion = "0.4.1"
let builderShimVersion = "0.4.0"
let builderShimVersion = "0.5.0"
let package = Package(
name: "container",
+8 -2
View File
@@ -267,6 +267,7 @@ extension Application {
}
unpackProgress.start()
var finalMessage = "Successfully built \(imageName)"
let taskManager = ProgressTaskCoordinator()
// Currently, only a single export can be specified.
for exp in exports {
@@ -285,14 +286,19 @@ extension Application {
try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: unpackProgress.handler))
}
case "tar":
break
guard let dest = exp.destination else {
throw ContainerizationError(.invalidArgument, message: "dest is required \(exp.rawValue)")
}
let tarURL = tempURL.appendingPathComponent("out.tar")
try FileManager.default.moveItem(at: tarURL, to: dest)
finalMessage = "Successfully exported to \(dest.absolutePath())"
default:
throw ContainerizationError(.invalidArgument, message: "invalid exporter \(exp.rawValue)")
}
}
await taskManager.finish()
unpackProgress.finish()
print("Successfully built \(imageName)")
print(finalMessage)
} catch {
throw NSError(domain: "Build", code: 1, userInfo: [NSLocalizedDescriptionKey: "\(error)"])
}
-129
View File
@@ -1,129 +0,0 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container 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 ContainerizationArchive
import Foundation
import GRPC
import NIO
actor BuildExporter: BuildPipelineHandler {
let output: OutputStream
let channel: AsyncThrowingStream<(AsyncStream<ClientStream>.Continuation, ServerStream), Swift.Error>.Continuation
public init(output: URL) throws {
guard let output = OutputStream(toFileAtPath: output.absolutePath(), append: true) else {
throw Error.couldNotInitializeOutput(output.absolutePath())
}
self.output = output
self.output.open()
var c: AsyncThrowingStream<(AsyncStream<ClientStream>.Continuation, ServerStream), Swift.Error>.Continuation?
let writeStream: AsyncThrowingStream<(AsyncStream<ClientStream>.Continuation, ServerStream), Swift.Error> = AsyncThrowingStream { continuation in
c = continuation
}
guard let c else {
throw Builder.Error.invalidContinuation
}
self.channel = c
Task.detached {
for try await packet in writeStream {
try await self.write(packet.0, packet.1)
}
}
}
nonisolated func accept(_ packet: ServerStream) throws -> Bool {
guard let buildTransfer = packet.getBuildTransfer() else {
return false
}
guard buildTransfer.stage() == "exporter" else {
return false
}
return true
}
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws {
self.channel.yield((sender, packet)) // guarantees ordering while being non-blocking
}
func write(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws {
guard let buildTransfer = packet.getBuildTransfer() else {
throw Error.buildTransferMissing
}
guard buildTransfer.stage() == "exporter" else {
throw Error.invalidStage(buildTransfer.stage() ?? "")
}
let buildID = packet.buildID
if buildTransfer.complete {
var transfer = BuildTransfer()
transfer.id = buildTransfer.id
transfer.direction = .outof
transfer.metadata = [
"os": "linux",
"stage": "exporter",
]
var response = ClientStream()
response.buildID = buildID
response.buildTransfer = transfer
response.packetType = .buildTransfer(transfer)
sender.yield(response)
self.output.close()
return
}
try buildTransfer.data.withUnsafeBytes { rawBuf in
let bufPointer = rawBuf.bindMemory(to: UInt8.self)
if let baseAddr = bufPointer.baseAddress, bufPointer.count > 0 {
let n = self.output.write(baseAddr, maxLength: bufPointer.count)
if n < 0 || n < bufPointer.count {
throw Error.writeError
}
}
}
var transfer = BuildTransfer()
transfer.id = buildTransfer.id
transfer.direction = .outof
transfer.metadata = [
"os": "linux",
"stage": "exporter",
]
var response = ClientStream()
response.buildID = buildID
response.buildTransfer = transfer
response.packetType = .buildTransfer(transfer)
sender.yield(response)
}
}
extension BuildExporter {
enum Error: Swift.Error, CustomStringConvertible {
case buildTransferMissing
case invalidStage(String)
case couldNotInitializeOutput(String)
case writeError
var description: String {
switch self {
case .buildTransferMissing:
return "buildTransfer field missing in packet"
case .invalidStage(let stage):
return "stage \(stage) is invalid, expected 'exporter'"
case .couldNotInitializeOutput(let output):
return "could not open \(output) for writing"
case .writeError:
return "write failed"
}
}
}
}
@@ -26,14 +26,8 @@ protocol BuildPipelineHandler: Sendable {
public actor BuildPipeline {
let handlers: [BuildPipelineHandler]
public init(_ config: Builder.BuildConfig) async throws {
let exporters: [BuildPipelineHandler] = try config.exports.map { export in
guard let destination = export.destination else {
throw Builder.Error.invalidExport(export.rawValue, "dest is required")
}
return try BuildExporter(output: destination)
}
self.handlers =
exporters + [
[
try BuildFSSync(URL(filePath: config.contextDir)),
try BuildRemoteContentProxy(config.contentStore),
try BuildImageResolver(config.contentStore),
@@ -0,0 +1,144 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container 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 Foundation
import Testing
extension TestCLIBuildBase {
class CLIBuilderTarExportTest: TestCLIBuildBase {
override init() throws {
try super.init()
}
deinit {
try? builderDelete(force: true)
}
@Test func testBuildExportTar() throws {
let tempDir: URL = try createTempDir()
let dockerfile: String =
"""
FROM scratch
ADD emptyFile /
"""
let context: [FileSystemEntry] = [
.file("emptyFile", content: .zeroFilled(size: 1))
]
try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context)
let exportPath = tempDir.appendingPathComponent("export.tar")
let response = try run(arguments: [
"build",
"-f", tempDir.appendingPathComponent("Dockerfile").path,
"-o", "type=tar,dest=\(exportPath.path)",
tempDir.appendingPathComponent("context").path,
])
#expect(response.status == 0, "build with tar export should succeed")
#expect(FileManager.default.fileExists(atPath: exportPath.path), "tar file should exist at \(exportPath.path)")
#expect(response.output.contains("Successfully exported to \(exportPath.path)"), "should show export success message")
let attributes = try FileManager.default.attributesOfItem(atPath: exportPath.path)
let fileSize = attributes[.size] as? Int ?? 0
#expect(fileSize > 0, "exported tar file should not be empty")
}
@Test func testBuildExportTarToDirectory() throws {
let tempDir: URL = try createTempDir()
let dockerfile: String =
"""
FROM ghcr.io/linuxcontainers/alpine:3.20
RUN echo "test content" > /test.txt
"""
try createContext(tempDir: tempDir, dockerfile: dockerfile)
let exportDir = tempDir.appendingPathComponent("exports")
try FileManager.default.createDirectory(at: exportDir, withIntermediateDirectories: true)
let response = try run(arguments: [
"build",
"-f", tempDir.appendingPathComponent("Dockerfile").path,
"-o", "type=tar,dest=\(exportDir.path)",
tempDir.appendingPathComponent("context").path,
])
#expect(response.status == 0, "build with tar export to directory should succeed")
let expectedTar = exportDir.appendingPathComponent("out.tar")
#expect(FileManager.default.fileExists(atPath: expectedTar.path), "tar file should exist at \(expectedTar.path)")
#expect(response.output.contains("Successfully exported to \(expectedTar.path)"), "should show export success message")
}
@Test func testBuildExportTarMultipleRuns() throws {
let tempDir: URL = try createTempDir()
let dockerfile: String =
"""
FROM scratch
ADD testFile /
"""
let context: [FileSystemEntry] = [
.file("testFile", content: .data("test data".data(using: .utf8)!))
]
try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context)
let exportDir = tempDir.appendingPathComponent("exports")
try FileManager.default.createDirectory(at: exportDir, withIntermediateDirectories: true)
// First build
var response = try run(arguments: [
"build",
"-f", tempDir.appendingPathComponent("Dockerfile").path,
"-o", "type=tar,dest=\(exportDir.path)",
tempDir.appendingPathComponent("context").path,
])
#expect(response.status == 0, "first build should succeed")
let firstTar = exportDir.appendingPathComponent("out.tar")
#expect(FileManager.default.fileExists(atPath: firstTar.path), "first tar should exist")
// Second build - should create out.tar.1
response = try run(arguments: [
"build",
"-f", tempDir.appendingPathComponent("Dockerfile").path,
"-o", "type=tar,dest=\(exportDir.path)",
tempDir.appendingPathComponent("context").path,
])
#expect(response.status == 0, "second build should succeed")
let secondTar = exportDir.appendingPathComponent("out.tar.1")
#expect(FileManager.default.fileExists(atPath: secondTar.path), "second tar should exist at out.tar.1")
}
@Test func testBuildExportTarInvalidDest() throws {
let tempDir: URL = try createTempDir()
let dockerfile: String =
"""
FROM scratch
"""
try createContext(tempDir: tempDir, dockerfile: dockerfile)
let response = try run(arguments: [
"build",
"-f", tempDir.appendingPathComponent("Dockerfile").path,
"-o", "type=tar", // Missing dest parameter
tempDir.appendingPathComponent("context").path,
])
#expect(response.status != 0, "build without dest should fail")
#expect(response.error.contains("dest field is required"), "error should mention missing dest")
}
}
}