//===----------------------------------------------------------------------===// // 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 @testable import ContainerBuild /* CLIBuildBase is the base class used for creating builder tests. Subtests classes // for these tests are nested in extensions of CLIBuildBase so that we can set // the serialized parallelization attribute across all builder tests. */ @Suite(.serialized) class TestCLIBuildBase: CLITest { override init() throws { try super.init() try? builderDelete(force: true) try builderStart() try waitForBuilderRunning() } deinit { try? builderDelete(force: true) } func waitForBuilderRunning() throws { let buildkitName = "buildkit" try waitForContainerRunning(buildkitName, 10) // exec into buildkit and check if builder-shim is running var attempt = 3 while attempt > 0 { attempt -= 1 let response = try doExec(name: buildkitName, cmd: ["pidof", "-s", "container-builder-shim"]) if !response.isEmpty { // found the init process running return } sleep(1) } throw CLIError.executionFailed("failed to wait for container-builder-shim process on \(buildkitName)") } func createTempDir() throws -> URL { let tempDir = testDir.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) return tempDir } func createContext(tempDir: URL, dockerfile: String, context: [FileSystemEntry]? = nil) throws { let dockerfileBytes = dockerfile.data(using: .utf8)! try dockerfileBytes.write(to: tempDir.appendingPathComponent("Dockerfile"), options: .atomic) let contextDir: URL = tempDir.appendingPathComponent("context").absoluteURL try FileManager.default.createDirectory(at: contextDir, withIntermediateDirectories: true, attributes: nil) if let context { for entry in context { try createEntry(entry, contextDir) } } } @discardableResult func build( tag: String, tempDir: URL, buildArgs: [String] = [], otherArgs: [String] = [] ) throws -> String { try buildWithPaths( tag: tag, tempContext: tempDir, tempDockerfileContext: tempDir, buildArgs: buildArgs, otherArgs: otherArgs ) } // buildWithPaths is a helper function for calling build with different paths for the build context and // the dockerfile path. If both paths are the same, use `build` func above. @discardableResult func buildWithPaths( tag: String, tempContext: URL, tempDockerfileContext: URL, buildArgs: [String] = [], otherArgs: [String] = [] ) throws -> String { let contextDir: URL = tempContext.appendingPathComponent("context") let contextDirPath = contextDir.absoluteURL.path var args = [ "build", "-f", tempDockerfileContext.appendingPathComponent("Dockerfile").path, "-t", tag, ] for arg in buildArgs { args.append("--build-arg") args.append(arg) } args.append(contextDirPath) args.append(contentsOf: otherArgs) let response = try run(arguments: args) if response.status != 0 { throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") } return response.output } 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 ) } func createEntry(_ entry: FileSystemEntry, _ contextDir: URL) throws { switch entry { // last 2 params are uid and gid case .file(let path, let content, let permissions, _, _): let fullPath = contextDir.appending(path: path) // not using .absoluteURL deletes the last component from fullPath let directory: URL = fullPath.absoluteURL.deletingLastPathComponent() let contentPath = fullPath.path try FileManager.default.createDirectory( atPath: directory.path, withIntermediateDirectories: true, attributes: nil ) switch content { case .data(let data): try data.write(to: fullPath) case .zeroFilled(let size): let fd = open(contentPath, O_CREAT | O_WRONLY, permissions.rawValue) if fd == -1 { throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) } defer { close(fd) } ftruncate(fd, off_t(size)) } // TODO: figure out why this block fails // try FileManager.default.setAttributes( // [ // .posixPermissions: Int(permissions.rawValue), // .ownerAccountID: uid, // .groupOwnerAccountID: gid, // ], // ofItemAtPath: fullPath.absoluteURL.absoluteString // ) case .directory(let path, let permissions, let uid, let gid): let fullPath = contextDir.appendingPathComponent(path).absoluteURL try FileManager.default.createDirectory( atPath: fullPath.path, withIntermediateDirectories: true, attributes: [ .posixPermissions: Int(permissions.rawValue), .ownerAccountID: uid, .groupOwnerAccountID: gid, ] ) case .symbolicLink(let path, let target, let uid, let gid): let fullPath = contextDir.appendingPathComponent(path).absoluteURL let directory: URL = fullPath.deletingLastPathComponent() try FileManager.default.createDirectory( atPath: directory.path, withIntermediateDirectories: true, attributes: nil ) let targetURL = contextDir.appendingPathComponent(target) try FileManager.default.createSymbolicLink( atPath: fullPath.path, withDestinationPath: targetURL.relativePathFrom(from: fullPath) ) lchown(fullPath.path, uid, gid) } } 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) } enum FileEntryContent { case zeroFilled(size: Int64) case data(Data) } func builderStart(cpus: Int64 = 2, memoryInGBs: Int64 = 2) throws { let (_, error, status) = try run(arguments: [ "builder", "start", "-c", "\(cpus)", "-m", "\(memoryInGBs)GB", ]) if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } } func builderStop() throws { let (_, error, status) = try run(arguments: [ "builder", "stop", ]) if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } } func builderDelete(force: Bool = false) throws { let (_, error, status) = try run( arguments: [ "builder", "delete", force ? "--force" : nil, ].compactMap { $0 }) if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } } }