diff --git a/Package.swift b/Package.swift index c9705fa0..5b2bf999 100644 --- a/Package.swift +++ b/Package.swift @@ -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", diff --git a/Sources/CLI/BuildCommand.swift b/Sources/CLI/BuildCommand.swift index 05426e1b..e24c677d 100644 --- a/Sources/CLI/BuildCommand.swift +++ b/Sources/CLI/BuildCommand.swift @@ -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)"]) } diff --git a/Sources/ContainerBuild/BuildExporter.swift b/Sources/ContainerBuild/BuildExporter.swift deleted file mode 100644 index 14ff3aaf..00000000 --- a/Sources/ContainerBuild/BuildExporter.swift +++ /dev/null @@ -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.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.Continuation, ServerStream), Swift.Error>.Continuation? - let writeStream: AsyncThrowingStream<(AsyncStream.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.Continuation, _ packet: ServerStream) async throws { - self.channel.yield((sender, packet)) // guarantees ordering while being non-blocking - } - - func write(_ sender: AsyncStream.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" - } - } - } -} diff --git a/Sources/ContainerBuild/BuildPipelineHandler.swift b/Sources/ContainerBuild/BuildPipelineHandler.swift index dea3d4bd..cb55c0f8 100644 --- a/Sources/ContainerBuild/BuildPipelineHandler.swift +++ b/Sources/ContainerBuild/BuildPipelineHandler.swift @@ -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), diff --git a/Tests/CLITests/Subcommands/Build/CLIBuilderTarExportTest.swift b/Tests/CLITests/Subcommands/Build/CLIBuilderTarExportTest.swift new file mode 100644 index 00000000..ed2a3bb2 --- /dev/null +++ b/Tests/CLITests/Subcommands/Build/CLIBuilderTarExportTest.swift @@ -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") + } + } +}