mirror of
https://github.com/apple/container.git
synced 2026-09-19 14:15:53 +00:00
add support for local build output (#369)
Fixes https://github.com/apple/container/issues/354
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "34bde5e432dcb2b7e4382f3694d95960f5df6a8255a990e34cc9d34f7435d862",
|
||||
"originHash" : "b75d239759865c54143e43f6c12839f0cb6a7ab70488af986fdf966ede58f78d",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "async-http-client",
|
||||
|
||||
+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.5.0"
|
||||
let builderShimVersion = "0.6.0"
|
||||
|
||||
let package = Package(
|
||||
name: "container",
|
||||
|
||||
@@ -292,6 +292,17 @@ extension Application {
|
||||
let tarURL = tempURL.appendingPathComponent("out.tar")
|
||||
try FileManager.default.moveItem(at: tarURL, to: dest)
|
||||
finalMessage = "Successfully exported to \(dest.absolutePath())"
|
||||
case "local":
|
||||
guard let dest = exp.destination else {
|
||||
throw ContainerizationError(.invalidArgument, message: "dest is required \(exp.rawValue)")
|
||||
}
|
||||
let localDir = tempURL.appendingPathComponent("local")
|
||||
|
||||
guard FileManager.default.fileExists(atPath: localDir.path) else {
|
||||
throw ContainerizationError(.invalidArgument, message: "expected local output not found")
|
||||
}
|
||||
try FileManager.default.copyItem(at: localDir, to: dest)
|
||||
finalMessage = "Successfully exported to \(dest.absolutePath())"
|
||||
default:
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid exporter \(exp.rawValue)")
|
||||
}
|
||||
|
||||
@@ -175,6 +175,10 @@ public struct Builder: Sendable {
|
||||
if destinationValue == nil {
|
||||
throw Builder.Error.invalidExport(input, "dest field is required")
|
||||
}
|
||||
case "local":
|
||||
if destinationValue == nil {
|
||||
throw Builder.Error.invalidExport(input, "dest field is required")
|
||||
}
|
||||
default:
|
||||
throw Builder.Error.invalidExport(input, "unsupported output type")
|
||||
}
|
||||
@@ -187,7 +191,7 @@ public struct Builder: Sendable {
|
||||
var components = ["type=\(type)"]
|
||||
|
||||
switch type {
|
||||
case "oci", "tar":
|
||||
case "oci", "tar", "local":
|
||||
break // ignore destination
|
||||
default:
|
||||
throw Builder.Error.invalidExport(rawValue, "unsupported output type")
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 CLIBuilderLocalOutputTest: TestCLIBuildBase {
|
||||
override init() throws {
|
||||
try super.init()
|
||||
}
|
||||
|
||||
deinit {
|
||||
try? builderDelete(force: true)
|
||||
}
|
||||
|
||||
@Test func testBuildLocalOutputHappyPath() throws {
|
||||
let tempDir: URL = try createTempDir()
|
||||
|
||||
// Test comprehensive multi-stage build with context and build arguments
|
||||
let dockerfile: String =
|
||||
"""
|
||||
ARG MESSAGE=default
|
||||
FROM scratch AS builder
|
||||
ADD build.txt /build.txt
|
||||
ADD testfile.txt /hello.txt
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder /build.txt /final.txt
|
||||
COPY --from=builder /hello.txt /app/hello.txt
|
||||
ADD message.txt /message.txt
|
||||
"""
|
||||
let context: [FileSystemEntry] = [
|
||||
.file("build.txt", content: .data("Building stage\n".data(using: .utf8)!)),
|
||||
.file("testfile.txt", content: .data("Hello from local build\n".data(using: .utf8)!)),
|
||||
.file("message.txt", content: .data("Hello from build args\n".data(using: .utf8)!)),
|
||||
]
|
||||
try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context)
|
||||
|
||||
let outputDir = tempDir.appendingPathComponent("comprehensive-local-output")
|
||||
let imageName = "local-comprehensive-test:\(UUID().uuidString)"
|
||||
|
||||
let response = try buildWithLocalOutput(
|
||||
tag: imageName,
|
||||
tempDir: tempDir,
|
||||
outputDir: outputDir,
|
||||
args: ["MESSAGE=Hello from build args"]
|
||||
)
|
||||
|
||||
// Verify the build succeeded
|
||||
#expect(response.contains("Successfully exported to"), "Expected successful local export message")
|
||||
|
||||
// Verify the output directory was created
|
||||
#expect(FileManager.default.fileExists(atPath: outputDir.path), "Expected local output directory to exist")
|
||||
|
||||
// Verify the output contains expected structure
|
||||
let contents = try FileManager.default.contentsOfDirectory(atPath: outputDir.path)
|
||||
#expect(!contents.isEmpty, "Expected local output directory to contain files")
|
||||
|
||||
// Test basic functionality - verify basic local output works
|
||||
let basicTempDir: URL = try createTempDir()
|
||||
let basicDockerfile: String =
|
||||
"""
|
||||
FROM scratch
|
||||
|
||||
ADD testfile.txt /hello.txt
|
||||
"""
|
||||
let basicContext: [FileSystemEntry] = [
|
||||
.file("testfile.txt", content: .data("Hello from basic build\n".data(using: .utf8)!))
|
||||
]
|
||||
try createContext(tempDir: basicTempDir, dockerfile: basicDockerfile, context: basicContext)
|
||||
|
||||
let basicOutputDir = basicTempDir.appendingPathComponent("basic-local-output")
|
||||
let basicImageName = "local-basic-test:\(UUID().uuidString)"
|
||||
|
||||
let basicResponse = try buildWithLocalOutput(tag: basicImageName, tempDir: basicTempDir, outputDir: basicOutputDir)
|
||||
|
||||
// Verify basic build succeeded
|
||||
#expect(basicResponse.contains("Successfully exported to"), "Expected successful basic local export message")
|
||||
#expect(FileManager.default.fileExists(atPath: basicOutputDir.path), "Expected basic local output directory to exist")
|
||||
|
||||
// Test context functionality - verify COPY works with context
|
||||
let contextTempDir: URL = try createTempDir()
|
||||
let contextDockerfile: String =
|
||||
"""
|
||||
FROM scratch
|
||||
|
||||
COPY testfile.txt /app/testfile.txt
|
||||
"""
|
||||
let contextContext: [FileSystemEntry] = [
|
||||
.file("testfile.txt", content: .data("Test content for context build\n".data(using: .utf8)!))
|
||||
]
|
||||
try createContext(tempDir: contextTempDir, dockerfile: contextDockerfile, context: contextContext)
|
||||
|
||||
let contextOutputDir = contextTempDir.appendingPathComponent("context-local-output")
|
||||
let contextImageName = "local-context-test:\(UUID().uuidString)"
|
||||
|
||||
let contextResponse = try buildWithLocalOutput(tag: contextImageName, tempDir: contextTempDir, outputDir: contextOutputDir)
|
||||
|
||||
// Verify context build succeeded
|
||||
#expect(contextResponse.contains("Successfully exported to"), "Expected successful context local export message")
|
||||
#expect(FileManager.default.fileExists(atPath: contextOutputDir.path), "Expected context local output directory to exist")
|
||||
}
|
||||
|
||||
@Test func testBuildLocalOutputEdgeCases() throws {
|
||||
// Test building with different context paths
|
||||
let dockerfileCtxDir: URL = try createTempDir()
|
||||
let dockerfile: String =
|
||||
"""
|
||||
FROM scratch
|
||||
|
||||
COPY . /app
|
||||
"""
|
||||
let dockerfileCtx: [FileSystemEntry] = [
|
||||
.file("dockerfile-context.txt", content: .data("Dockerfile context file\n".data(using: .utf8)!))
|
||||
]
|
||||
try createContext(tempDir: dockerfileCtxDir, dockerfile: dockerfile, context: dockerfileCtx)
|
||||
|
||||
let buildContextDir: URL = try createTempDir()
|
||||
let buildContext: [FileSystemEntry] = [
|
||||
.file("build-context.txt", content: .data("Build context file\n".data(using: .utf8)!))
|
||||
]
|
||||
try createContext(tempDir: buildContextDir, dockerfile: "", context: buildContext)
|
||||
|
||||
let outputDir = dockerfileCtxDir.appendingPathComponent("diffpaths-local-output")
|
||||
let imageName = "local-diffpaths-test:\(UUID().uuidString)"
|
||||
|
||||
let response = try buildWithPathsAndLocalOutput(
|
||||
tag: imageName,
|
||||
tempContext: buildContextDir,
|
||||
tempDockerfileContext: dockerfileCtxDir,
|
||||
outputDir: outputDir
|
||||
)
|
||||
|
||||
// Verify the build succeeded
|
||||
#expect(response.contains("Successfully exported to"), "Expected successful local export message")
|
||||
|
||||
// Verify the output directory exists
|
||||
#expect(FileManager.default.fileExists(atPath: outputDir.path), "Expected local output directory to exist")
|
||||
|
||||
// Test building to existing output directory
|
||||
let existingTempDir: URL = try createTempDir()
|
||||
let existingDockerfile: String =
|
||||
"""
|
||||
FROM scratch
|
||||
|
||||
ADD newfile.txt /newfile.txt
|
||||
"""
|
||||
let existingContext: [FileSystemEntry] = [
|
||||
.file("newfile.txt", content: .data("New content from build\n".data(using: .utf8)!))
|
||||
]
|
||||
try createContext(tempDir: existingTempDir, dockerfile: existingDockerfile, context: existingContext)
|
||||
|
||||
let existingOutputDir = existingTempDir.appendingPathComponent("existing-output")
|
||||
|
||||
// Create the output directory and add some existing files
|
||||
try FileManager.default.createDirectory(at: existingOutputDir, withIntermediateDirectories: true)
|
||||
let existingFile = existingOutputDir.appendingPathComponent("existing.txt")
|
||||
try "Existing file content\n".data(using: .utf8)!.write(to: existingFile)
|
||||
|
||||
let existingImageName = "local-existing-test:\(UUID().uuidString)"
|
||||
|
||||
let existingResponse = try buildWithLocalOutput(tag: existingImageName, tempDir: existingTempDir, outputDir: existingOutputDir)
|
||||
|
||||
// Verify the build succeeded
|
||||
#expect(existingResponse.contains("Successfully exported to"), "Expected successful local export message")
|
||||
|
||||
// Verify the output directory exists
|
||||
#expect(FileManager.default.fileExists(atPath: existingOutputDir.path), "Expected local output directory to exist")
|
||||
|
||||
// Verify the existing file is still there (local output should merge/overwrite)
|
||||
let contents = try FileManager.default.contentsOfDirectory(atPath: existingOutputDir.path)
|
||||
#expect(!contents.isEmpty, "Expected local output directory to contain files")
|
||||
|
||||
// The behavior may vary - local output might overwrite the directory or merge contents
|
||||
// This test verifies that the operation completes successfully with an existing directory
|
||||
}
|
||||
|
||||
@Test func testBuildLocalOutputFailure() throws {
|
||||
let tempDir: URL = try createTempDir()
|
||||
let dockerfile: String =
|
||||
"""
|
||||
FROM scratch
|
||||
|
||||
ADD test.txt /test.txt
|
||||
"""
|
||||
let context: [FileSystemEntry] = [
|
||||
.file("test.txt", content: .data("test\n".data(using: .utf8)!))
|
||||
]
|
||||
try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context)
|
||||
|
||||
// Use a path that doesn't exist and can't be created (invalid parent)
|
||||
let invalidOutputDir = URL(fileURLWithPath: "/nonexistent/invalid/path")
|
||||
let imageName = "local-invalid-test:\(UUID().uuidString)"
|
||||
|
||||
#expect(throws: CLIError.self) {
|
||||
try buildWithLocalOutput(tag: imageName, tempDir: tempDir, outputDir: invalidOutputDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to build with local output
|
||||
@discardableResult
|
||||
func buildWithLocalOutput(tag: String, tempDir: URL, outputDir: URL, args: [String]? = nil) throws -> String {
|
||||
try buildWithPathsAndLocalOutput(
|
||||
tag: tag,
|
||||
tempContext: tempDir,
|
||||
tempDockerfileContext: tempDir,
|
||||
outputDir: outputDir,
|
||||
args: args
|
||||
)
|
||||
}
|
||||
|
||||
// Helper function to build with different paths and local output
|
||||
@discardableResult
|
||||
func buildWithPathsAndLocalOutput(
|
||||
tag: String,
|
||||
tempContext: URL,
|
||||
tempDockerfileContext: URL,
|
||||
outputDir: URL,
|
||||
args: [String]? = nil
|
||||
) throws -> String {
|
||||
let contextDir: URL = tempContext.appendingPathComponent("context")
|
||||
let contextDirPath = contextDir.absoluteURL.path
|
||||
var buildArgs = [
|
||||
"build",
|
||||
"-f",
|
||||
tempDockerfileContext.appendingPathComponent("Dockerfile").path,
|
||||
"-t",
|
||||
tag,
|
||||
"--output",
|
||||
"type=local,dest=\(outputDir.path)",
|
||||
]
|
||||
if let args = args {
|
||||
for arg in args {
|
||||
buildArgs.append("--build-arg")
|
||||
buildArgs.append(arg)
|
||||
}
|
||||
}
|
||||
buildArgs.append(contextDirPath)
|
||||
|
||||
let response = try run(arguments: buildArgs)
|
||||
if response.status != 0 {
|
||||
throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)")
|
||||
}
|
||||
|
||||
return response.output
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user