Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 8 additions & 2 deletions Sources/CLI/BuildCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)"])
}
Expand Down
129 changes: 0 additions & 129 deletions Sources/ContainerBuild/BuildExporter.swift

This file was deleted.

8 changes: 1 addition & 7 deletions Sources/ContainerBuild/BuildPipelineHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
144 changes: 144 additions & 0 deletions Tests/CLITests/Subcommands/Build/CLIBuilderTarExportTest.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
}