mirror of
https://github.com/apple/container.git
synced 2026-07-12 20:47:04 +00:00
[Build] fixes for tar output mode (#353)
Fixes https://github.com/apple/container/issues/347
This commit is contained in:
+1
-1
@@ -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",
|
||||
|
||||
@@ -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)"])
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user