mirror of
https://github.com/apple/container.git
synced 2026-07-13 21:17:05 +00:00
9bd3c47345
- Part of #1833.
356 lines
15 KiB
Swift
356 lines
15 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// Copyright © 2026 Apple Inc. and the container project authors.
|
|
//
|
|
// 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 ContainerizationExtras
|
|
import Darwin
|
|
import Foundation
|
|
import SystemPackage
|
|
import Testing
|
|
|
|
// MARK: - Build context types
|
|
|
|
extension ContainerFixture {
|
|
/// A file-system entry to materialize inside a build context directory.
|
|
enum FileSystemEntry {
|
|
case file(
|
|
_ path: String,
|
|
content: FileEntryContent,
|
|
permissions: FilePermissions = [.r, .w, .gr, .gw, .or, .ow],
|
|
uid: uid_t = 0,
|
|
gid: gid_t = 0
|
|
)
|
|
case directory(
|
|
_ path: String,
|
|
permissions: FilePermissions = [.r, .w, .x, .gr, .gw, .gx, .or, .ow, .ox],
|
|
uid: uid_t = 0,
|
|
gid: gid_t = 0
|
|
)
|
|
case symbolicLink(_ path: String, target: String, uid: uid_t = 0, gid: gid_t = 0)
|
|
}
|
|
|
|
enum FileEntryContent {
|
|
case zeroFilled(size: Int64)
|
|
case data(Data)
|
|
}
|
|
|
|
struct FilePermissions: OptionSet {
|
|
let rawValue: UInt16
|
|
static let r = FilePermissions(rawValue: 0o400)
|
|
static let w = FilePermissions(rawValue: 0o200)
|
|
static let x = FilePermissions(rawValue: 0o100)
|
|
static let gr = FilePermissions(rawValue: 0o040)
|
|
static let gw = FilePermissions(rawValue: 0o020)
|
|
static let gx = FilePermissions(rawValue: 0o010)
|
|
static let or = FilePermissions(rawValue: 0o004)
|
|
static let ow = FilePermissions(rawValue: 0o002)
|
|
static let ox = FilePermissions(rawValue: 0o001)
|
|
}
|
|
}
|
|
|
|
// MARK: - Builder lifecycle helpers
|
|
|
|
extension ContainerFixture {
|
|
|
|
/// Starts the buildkit builder container.
|
|
func builderStart(cpus: Int64 = 2, memoryInGBs: Int64 = 2) throws {
|
|
try run(["builder", "start", "-c", "\(cpus)", "-m", "\(memoryInGBs)GB"]).check()
|
|
}
|
|
|
|
/// Stops the buildkit builder container.
|
|
func builderStop() throws {
|
|
try run(["builder", "stop"]).check()
|
|
}
|
|
|
|
/// Deletes the buildkit builder container.
|
|
func builderDelete(force: Bool = false) throws {
|
|
var args = ["builder", "delete"]
|
|
if force { args.append("--force") }
|
|
try run(args).check()
|
|
}
|
|
|
|
/// Polls until the buildkit container is running and the builder shim is ready.
|
|
func waitForBuilderRunning() async throws {
|
|
try await waitForContainerRunning("buildkit", attempts: 10)
|
|
for _ in 0..<3 {
|
|
let response = try? doExec("buildkit", cmd: ["pidof", "-s", "container-builder-shim"])
|
|
if let r = response, !r.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
return
|
|
}
|
|
try await Task.sleep(for: .seconds(1))
|
|
}
|
|
throw CommandError.executionFailed("timed out waiting for container-builder-shim on buildkit")
|
|
}
|
|
|
|
/// Deletes any existing builder, starts a fresh one, runs `body`, then deletes the builder.
|
|
///
|
|
/// Each build test gets an isolated builder to avoid inter-test contamination.
|
|
/// Acquires a process-wide lock so only one test holds the buildkit singleton at a time,
|
|
/// regardless of how many suites run concurrently in the global pass.
|
|
func withBuilder(
|
|
cpus: Int64 = 2,
|
|
memoryInGBs: Int64 = 2,
|
|
_ body: @Sendable (ContainerFixture) async throws -> Void
|
|
) async throws {
|
|
try await withoutActuallyEscaping(body) { escapingBody in
|
|
try await Self.builderLock.withLock { _ in
|
|
_ = try? self.run(["builder", "delete", "--force"])
|
|
try self.builderStart(cpus: cpus, memoryInGBs: memoryInGBs)
|
|
defer { _ = try? self.run(["builder", "delete", "--force"]) }
|
|
try await self.waitForBuilderRunning()
|
|
try await escapingBody(self)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Acquires the process-wide builder lock without starting a builder.
|
|
///
|
|
/// Use this in tests that manually manage the builder lifecycle (e.g. lifecycle
|
|
/// tests that call ``builderStart()``/``builderStop()`` directly) so they
|
|
/// serialise correctly with tests that use ``withBuilder(_:)``.
|
|
func withBuilderLock<T: Sendable>(_ body: @Sendable () async throws -> T) async throws -> T {
|
|
try await withoutActuallyEscaping(body) { escapingBody in
|
|
try await Self.builderLock.withLock { _ in
|
|
try await escapingBody()
|
|
}
|
|
}
|
|
}
|
|
|
|
private static let builderLock = AsyncLock()
|
|
}
|
|
|
|
// MARK: - Build context helpers
|
|
|
|
extension ContainerFixture {
|
|
|
|
/// Creates a new scratch directory under ``testDir`` and returns its path.
|
|
///
|
|
/// The directory is removed automatically when the fixture scope exits.
|
|
func createTempDir() throws -> FilePath {
|
|
let dir = testDir.appending(UUID().uuidString)
|
|
try FileManager.default.createDirectory(
|
|
atPath: dir.string, withIntermediateDirectories: true, attributes: nil)
|
|
return dir
|
|
}
|
|
|
|
/// Writes `contents` to a new file under ``testDir`` with the given suffix.
|
|
func createTempFile(suffix: String, contents: Data) throws -> FilePath {
|
|
let file = testDir.appending(UUID().uuidString + suffix)
|
|
try contents.write(to: URL(filePath: file.string), options: .atomic)
|
|
return file
|
|
}
|
|
|
|
/// Writes a Dockerfile and optional context entries into `dir`.
|
|
///
|
|
/// Creates `dir/Dockerfile` (if `dockerfile` is non-empty) and
|
|
/// `dir/context/` populated with `context` entries.
|
|
func createContext(dir: FilePath, dockerfile: String, context: [FileSystemEntry]? = nil) throws {
|
|
if !dockerfile.isEmpty {
|
|
try Data(dockerfile.utf8).write(to: URL(filePath: dir.appending("Dockerfile").string), options: .atomic)
|
|
}
|
|
let contextDir = dir.appending("context")
|
|
try FileManager.default.createDirectory(
|
|
atPath: contextDir.string, withIntermediateDirectories: true, attributes: nil)
|
|
for entry in context ?? [] {
|
|
try createEntry(entry, contextDir: contextDir)
|
|
}
|
|
}
|
|
|
|
/// Materializes a ``FileSystemEntry`` inside `contextDir`.
|
|
func createEntry(_ entry: FileSystemEntry, contextDir: FilePath) throws {
|
|
switch entry {
|
|
case .file(let path, let content, let permissions, let uid, let gid):
|
|
let fullPath = appendingRelative(contextDir, path)
|
|
let parentDir = fullPath.string.components(separatedBy: "/").dropLast().joined(separator: "/")
|
|
try FileManager.default.createDirectory(
|
|
atPath: parentDir, withIntermediateDirectories: true, attributes: nil)
|
|
switch content {
|
|
case .data(let data):
|
|
try data.write(to: URL(filePath: fullPath.string), options: .atomic)
|
|
case .zeroFilled(let size):
|
|
let zeros = Data(count: Int(size))
|
|
try zeros.write(to: URL(filePath: fullPath.string), options: .atomic)
|
|
}
|
|
// Set permissions explicitly so they match the requested mode regardless of umask.
|
|
try FileManager.default.setAttributes(
|
|
[.posixPermissions: Int(permissions.rawValue)],
|
|
ofItemAtPath: fullPath.string)
|
|
// Ownership change silently ignored when not running as root.
|
|
_ = lchown(fullPath.string, uid, gid)
|
|
|
|
case .directory(let path, let permissions, let uid, let gid):
|
|
let fullPath = appendingRelative(contextDir, path)
|
|
try FileManager.default.createDirectory(
|
|
atPath: fullPath.string,
|
|
withIntermediateDirectories: true,
|
|
attributes: [
|
|
.posixPermissions: Int(permissions.rawValue),
|
|
.ownerAccountID: uid,
|
|
.groupOwnerAccountID: gid,
|
|
])
|
|
|
|
case .symbolicLink(let path, let target, let uid, let gid):
|
|
let fullPath = appendingRelative(contextDir, path)
|
|
let parentDir = fullPath.string.components(separatedBy: "/").dropLast().joined(separator: "/")
|
|
try FileManager.default.createDirectory(
|
|
atPath: parentDir, withIntermediateDirectories: true, attributes: nil)
|
|
let targetPath = appendingRelative(contextDir, target)
|
|
let relativeDest = relativePathFrom(targetPath, from: fullPath)
|
|
try FileManager.default.createSymbolicLink(
|
|
atPath: fullPath.string, withDestinationPath: relativeDest)
|
|
lchown(fullPath.string, uid, gid)
|
|
}
|
|
}
|
|
|
|
/// Appends a multi-component relative path (e.g. `"a/b/c"`) to a `FilePath` base.
|
|
private func appendingRelative(_ base: FilePath, _ relative: String) -> FilePath {
|
|
relative.split(separator: "/", omittingEmptySubsequences: true)
|
|
.reduce(base) { $0.appending(String($1)) }
|
|
}
|
|
|
|
/// Computes the relative path from `base` to `dest`.
|
|
///
|
|
/// - FIXME: This duplicates logic in `ContainerBuild/URL+Extensions.swift`.
|
|
/// Both copies should be extracted to `ContainerizationOS/FilePathOps`
|
|
/// in the containerization package.
|
|
private func relativePathFrom(_ dest: FilePath, from base: FilePath) -> String {
|
|
let destParts = dest.string.components(separatedBy: "/").filter { !$0.isEmpty }
|
|
let baseParts = base.string.components(separatedBy: "/").filter { !$0.isEmpty }
|
|
let common = zip(destParts, baseParts).prefix { $0.0 == $0.1 }.count
|
|
guard common > 0 else { return dest.string }
|
|
let ups = Array(repeating: "..", count: baseParts.count - common)
|
|
let remainder = Array(destParts.dropFirst(common))
|
|
return (ups + remainder).joined(separator: "/")
|
|
}
|
|
}
|
|
|
|
// MARK: - Build invocation helpers
|
|
|
|
extension ContainerFixture {
|
|
|
|
/// Builds an image from `contextDir/Dockerfile` with context `contextDir/context/`.
|
|
@discardableResult
|
|
func build(
|
|
tag: String,
|
|
contextDir: FilePath = FilePath("."),
|
|
buildArgs: [String] = [],
|
|
otherArgs: [String] = []
|
|
) throws -> String {
|
|
try buildWithPaths(tags: [tag], contextDir: contextDir, buildArgs: buildArgs, otherArgs: otherArgs)
|
|
}
|
|
|
|
/// Builds using a context directory and an optional explicit Dockerfile path.
|
|
///
|
|
/// Mirrors `container build [-f dockerfilePath] [contextDir]`:
|
|
/// - `tags` defaults to `[]`; when empty the runtime auto-generates a UUID tag
|
|
/// and prints it to stdout (call `.trimmingCharacters(in: .whitespacesAndNewlines)`
|
|
/// on the return value to obtain it)
|
|
/// - `contextDir` defaults to the current directory (`.`)
|
|
/// - `dockerfilePath` defaults to `nil`, resolved to `contextDir/Dockerfile` at call time
|
|
@discardableResult
|
|
func buildWithPaths(
|
|
tags: [String] = [],
|
|
contextDir: FilePath = FilePath("."),
|
|
dockerfilePath: FilePath? = nil,
|
|
buildArgs: [String] = [],
|
|
otherArgs: [String] = []
|
|
) throws -> String {
|
|
let contextPath = contextDir.appending("context")
|
|
let resolvedDockerfile = dockerfilePath ?? contextDir.appending("Dockerfile")
|
|
var args = ["build", "-f", resolvedDockerfile.string]
|
|
for tag in tags { args += ["-t", tag] }
|
|
for arg in buildArgs { args += ["--build-arg", arg] }
|
|
args.append(contextPath.string)
|
|
args.append(contentsOf: otherArgs)
|
|
let result = try run(args)
|
|
guard result.status == 0 else {
|
|
throw CommandError.executionFailed(
|
|
"build failed: stdout=\(result.output) stderr=\(result.error)")
|
|
}
|
|
return result.output
|
|
}
|
|
|
|
/// Builds with a Dockerfile read from stdin.
|
|
@discardableResult
|
|
func buildWithStdin(
|
|
tags: [String],
|
|
contextDir: FilePath,
|
|
dockerfileContents: String,
|
|
buildArgs: [String] = [],
|
|
otherArgs: [String] = []
|
|
) throws -> String {
|
|
let contextPath = contextDir.appending("context")
|
|
var args = ["build", "-f", "-"]
|
|
for tag in tags { args += ["-t", tag] }
|
|
for arg in buildArgs { args += ["--build-arg", arg] }
|
|
args.append(contextPath.string)
|
|
args.append(contentsOf: otherArgs)
|
|
let result = try run(args, stdin: Data(dockerfileContents.utf8))
|
|
guard result.status == 0 else {
|
|
throw CommandError.executionFailed(
|
|
"build failed: stdout=\(result.output) stderr=\(result.error)")
|
|
}
|
|
return result.output
|
|
}
|
|
|
|
/// Builds with `--output type=local,dest=<outputDir>`.
|
|
@discardableResult
|
|
func buildWithPathsAndLocalOutput(
|
|
tag: String,
|
|
contextDir: FilePath = FilePath("."),
|
|
dockerfilePath: FilePath? = nil,
|
|
outputDir: FilePath,
|
|
buildArgs: [String] = []
|
|
) throws -> String {
|
|
let contextPath = contextDir.appending("context")
|
|
let resolvedDockerfile = dockerfilePath ?? contextDir.appending("Dockerfile")
|
|
var args = [
|
|
"build",
|
|
"-f", resolvedDockerfile.string,
|
|
"-t", tag,
|
|
"--output", "type=local,dest=\(outputDir.string)",
|
|
]
|
|
for arg in buildArgs { args += ["--build-arg", arg] }
|
|
args.append(contextPath.string)
|
|
let result = try run(args)
|
|
guard result.status == 0 else {
|
|
throw CommandError.executionFailed(
|
|
"build failed: stdout=\(result.output) stderr=\(result.error)")
|
|
}
|
|
return result.output
|
|
}
|
|
}
|
|
|
|
// MARK: - Container exec helpers
|
|
|
|
extension ContainerFixture {
|
|
/// Returns true if `path` exists as a regular file inside `container`.
|
|
func containerHasFile(_ container: String, at path: String) throws -> Bool {
|
|
try run(["exec", container, "test", "-f", path]).status == 0
|
|
}
|
|
|
|
/// Asserts that `path` exists as a regular file inside `container`.
|
|
func assertContainerHasFile(_ container: String, at path: String, _ comment: String? = nil) throws {
|
|
let exists = try containerHasFile(container, at: path)
|
|
#expect(exists, "\(comment ?? path) should exist in container")
|
|
}
|
|
|
|
/// Asserts that `path` does NOT exist inside `container`.
|
|
func assertContainerMissingFile(_ container: String, at path: String, _ comment: String? = nil) throws {
|
|
let exists = try containerHasFile(container, at: path)
|
|
#expect(!exists, "\(comment ?? path) should NOT exist in container")
|
|
}
|
|
}
|