chore: import upstream snapshot with attribution
container project - merge build / Invoke build (push) Successful in 1s
container project - merge build / Invoke build (push) Successful in 1s
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-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 Containerization
|
||||
import ContainerizationOCI
|
||||
|
||||
public typealias IO = Com_Apple_Container_Build_V1_IO
|
||||
public typealias InfoRequest = Com_Apple_Container_Build_V1_InfoRequest
|
||||
public typealias InfoResponse = Com_Apple_Container_Build_V1_InfoResponse
|
||||
public typealias ClientStream = Com_Apple_Container_Build_V1_ClientStream
|
||||
public typealias ServerStream = Com_Apple_Container_Build_V1_ServerStream
|
||||
public typealias ImageTransfer = Com_Apple_Container_Build_V1_ImageTransfer
|
||||
public typealias BuildTransfer = Com_Apple_Container_Build_V1_BuildTransfer
|
||||
|
||||
extension BuildTransfer {
|
||||
func stage() -> String? {
|
||||
let stage = self.metadata["stage"]
|
||||
return stage == "" ? nil : stage
|
||||
}
|
||||
|
||||
func method() -> String? {
|
||||
let method = self.metadata["method"]
|
||||
return method == "" ? nil : method
|
||||
}
|
||||
|
||||
func includePatterns() -> [String]? {
|
||||
guard let includePatternsString = self.metadata["include-patterns"] else {
|
||||
return nil
|
||||
}
|
||||
return includePatternsString == "" ? nil : includePatternsString.components(separatedBy: ",")
|
||||
}
|
||||
|
||||
func followPaths() -> [String]? {
|
||||
guard let followPathString = self.metadata["followpaths"] else {
|
||||
return nil
|
||||
}
|
||||
return followPathString == "" ? nil : followPathString.components(separatedBy: ",")
|
||||
}
|
||||
|
||||
func mode() -> String? {
|
||||
self.metadata["mode"]
|
||||
}
|
||||
|
||||
func size() -> Int? {
|
||||
guard let sizeStr = self.metadata["size"] else {
|
||||
return nil
|
||||
}
|
||||
return sizeStr == "" ? nil : Int(sizeStr)
|
||||
}
|
||||
|
||||
func offset() -> UInt64? {
|
||||
guard let offsetStr = self.metadata["offset"] else {
|
||||
return nil
|
||||
}
|
||||
return offsetStr == "" ? nil : UInt64(offsetStr)
|
||||
}
|
||||
|
||||
func len() -> Int? {
|
||||
guard let lenStr = self.metadata["length"] else {
|
||||
return nil
|
||||
}
|
||||
return lenStr == "" ? nil : Int(lenStr)
|
||||
}
|
||||
}
|
||||
|
||||
extension ImageTransfer {
|
||||
func stage() -> String? {
|
||||
self.metadata["stage"]
|
||||
}
|
||||
|
||||
func method() -> String? {
|
||||
self.metadata["method"]
|
||||
}
|
||||
|
||||
func ref() -> String? {
|
||||
self.metadata["ref"]
|
||||
}
|
||||
|
||||
func platform() throws -> Platform? {
|
||||
let metadata = self.metadata
|
||||
guard let platform = metadata["platform"] else {
|
||||
return nil
|
||||
}
|
||||
return try Platform(from: platform)
|
||||
}
|
||||
|
||||
func mode() -> String? {
|
||||
self.metadata["mode"]
|
||||
}
|
||||
|
||||
func size() -> Int? {
|
||||
let metadata = self.metadata
|
||||
guard let sizeStr = metadata["size"] else {
|
||||
return nil
|
||||
}
|
||||
return Int(sizeStr)
|
||||
}
|
||||
|
||||
func len() -> Int? {
|
||||
let metadata = self.metadata
|
||||
guard let lenStr = metadata["length"] else {
|
||||
return nil
|
||||
}
|
||||
return Int(lenStr)
|
||||
}
|
||||
|
||||
func offset() -> UInt64? {
|
||||
let metadata = self.metadata
|
||||
guard let offsetStr = metadata["offset"] else {
|
||||
return nil
|
||||
}
|
||||
return UInt64(offsetStr)
|
||||
}
|
||||
}
|
||||
|
||||
extension ServerStream {
|
||||
func getImageTransfer() -> ImageTransfer? {
|
||||
if case .imageTransfer(let v) = self.packetType {
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getBuildTransfer() -> BuildTransfer? {
|
||||
if case .buildTransfer(let v) = self.packetType {
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getIO() -> IO? {
|
||||
if case .io(let v) = self.packetType {
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-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 Collections
|
||||
import ContainerAPIClient
|
||||
import ContainerizationArchive
|
||||
import ContainerizationOCI
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import GRPCCore
|
||||
|
||||
actor BuildFSSync: BuildPipelineHandler {
|
||||
let contextDir: URL
|
||||
|
||||
init(_ contextDir: URL) throws {
|
||||
guard FileManager.default.fileExists(atPath: contextDir.cleanPath) else {
|
||||
throw Error.contextNotFound(contextDir.cleanPath)
|
||||
}
|
||||
guard try contextDir.isDir() else {
|
||||
throw Error.contextIsNotDirectory(contextDir.cleanPath)
|
||||
}
|
||||
|
||||
self.contextDir = contextDir
|
||||
}
|
||||
|
||||
nonisolated func accept(_ packet: ServerStream) throws -> Bool {
|
||||
guard let buildTransfer = packet.getBuildTransfer() else {
|
||||
return false
|
||||
}
|
||||
guard buildTransfer.stage() == "fssync" else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws {
|
||||
guard let buildTransfer = packet.getBuildTransfer() else {
|
||||
throw Error.buildTransferMissing
|
||||
}
|
||||
guard let method = buildTransfer.method() else {
|
||||
throw Error.methodMissing
|
||||
}
|
||||
switch try FSSyncMethod(method) {
|
||||
case .read:
|
||||
try await self.read(sender, buildTransfer, packet.buildID)
|
||||
case .info:
|
||||
try await self.info(sender, buildTransfer, packet.buildID)
|
||||
case .walk:
|
||||
try await self.walk(sender, buildTransfer, packet.buildID)
|
||||
}
|
||||
}
|
||||
|
||||
func read(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
|
||||
let offset: UInt64 = packet.offset() ?? 0
|
||||
let size: Int = packet.len() ?? 0
|
||||
var path: URL
|
||||
if packet.source.hasPrefix("/") {
|
||||
path = URL(fileURLWithPath: packet.source).standardizedFileURL
|
||||
} else {
|
||||
path =
|
||||
contextDir
|
||||
.appendingPathComponent(packet.source)
|
||||
.standardizedFileURL
|
||||
}
|
||||
if !FileManager.default.fileExists(atPath: path.cleanPath) {
|
||||
path = URL(filePath: self.contextDir.cleanPath)
|
||||
path.append(components: packet.source.cleanPathComponent)
|
||||
}
|
||||
let data = try {
|
||||
if try path.isDir() {
|
||||
return Data()
|
||||
}
|
||||
let file = try LocalContent(path: path.standardizedFileURL)
|
||||
return try file.data(offset: offset, length: size) ?? Data()
|
||||
}()
|
||||
|
||||
let transfer = try path.buildTransfer(id: packet.id, contextDir: self.contextDir, complete: true, data: data)
|
||||
var response = ClientStream()
|
||||
response.buildID = buildID
|
||||
response.buildTransfer = transfer
|
||||
response.packetType = .buildTransfer(transfer)
|
||||
sender.yield(response)
|
||||
}
|
||||
|
||||
func info(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
|
||||
let path: URL
|
||||
if packet.source.hasPrefix("/") {
|
||||
path = URL(fileURLWithPath: packet.source).standardizedFileURL
|
||||
} else {
|
||||
path =
|
||||
contextDir
|
||||
.appendingPathComponent(packet.source)
|
||||
.standardizedFileURL
|
||||
}
|
||||
let transfer = try path.buildTransfer(id: packet.id, contextDir: self.contextDir, complete: true)
|
||||
var response = ClientStream()
|
||||
response.buildID = buildID
|
||||
response.buildTransfer = transfer
|
||||
response.packetType = .buildTransfer(transfer)
|
||||
sender.yield(response)
|
||||
}
|
||||
|
||||
private struct DirEntry: Hashable {
|
||||
let url: URL
|
||||
let isDirectory: Bool
|
||||
let relativePath: String
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(relativePath)
|
||||
}
|
||||
|
||||
static func == (lhs: DirEntry, rhs: DirEntry) -> Bool {
|
||||
lhs.relativePath == rhs.relativePath
|
||||
}
|
||||
}
|
||||
|
||||
func walk(
|
||||
_ sender: AsyncStream<ClientStream>.Continuation,
|
||||
_ packet: BuildTransfer,
|
||||
_ buildID: String
|
||||
) async throws {
|
||||
let wantsTar = packet.mode() == "tar"
|
||||
|
||||
var entries: [String: Set<DirEntry>] = [:]
|
||||
let followPaths: [String] = packet.followPaths() ?? []
|
||||
|
||||
let followPathsWalked = try walk(root: self.contextDir, includePatterns: followPaths)
|
||||
for url in followPathsWalked {
|
||||
guard self.contextDir.absoluteURL.cleanPath != url.absoluteURL.cleanPath else {
|
||||
continue
|
||||
}
|
||||
guard self.contextDir.parentOf(url) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let relPath = try url.relativeChildPath(to: contextDir)
|
||||
let parentPath = try url.deletingLastPathComponent().relativeChildPath(to: contextDir)
|
||||
let entry = DirEntry(url: url, isDirectory: url.hasDirectoryPath, relativePath: relPath)
|
||||
entries[parentPath, default: []].insert(entry)
|
||||
|
||||
if url.isSymlink {
|
||||
let target: URL = url.resolvingSymlinksInPath()
|
||||
if self.contextDir.parentOf(target) {
|
||||
let relPath = try target.relativeChildPath(to: self.contextDir)
|
||||
let entry = DirEntry(url: target, isDirectory: target.hasDirectoryPath, relativePath: relPath)
|
||||
let parentPath: String = try target.deletingLastPathComponent().relativeChildPath(to: self.contextDir)
|
||||
entries[parentPath, default: []].insert(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var fileOrder = [String]()
|
||||
try processDirectory("", inputEntries: entries, processedPaths: &fileOrder)
|
||||
|
||||
if !wantsTar {
|
||||
let fileInfos = try fileOrder.map { rel -> FileInfo in
|
||||
try FileInfo(path: contextDir.appendingPathComponent(rel), contextDir: contextDir)
|
||||
}
|
||||
|
||||
let data = try JSONEncoder().encode(fileInfos)
|
||||
let transfer = BuildTransfer(
|
||||
id: packet.id,
|
||||
source: packet.source,
|
||||
complete: true,
|
||||
isDir: false,
|
||||
metadata: [
|
||||
"os": "linux",
|
||||
"stage": "fssync",
|
||||
"mode": "json",
|
||||
],
|
||||
data: data
|
||||
)
|
||||
var resp = ClientStream()
|
||||
resp.buildID = buildID
|
||||
resp.buildTransfer = transfer
|
||||
resp.packetType = .buildTransfer(transfer)
|
||||
sender.yield(resp)
|
||||
return
|
||||
}
|
||||
|
||||
let tarURL = URL.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString + ".tar")
|
||||
|
||||
defer { try? FileManager.default.removeItem(at: tarURL) }
|
||||
|
||||
let writerCfg = ArchiveWriterConfiguration(
|
||||
format: .paxRestricted,
|
||||
filter: .none)
|
||||
|
||||
let tarHash = try Archiver.compress(
|
||||
source: contextDir,
|
||||
destination: tarURL,
|
||||
writerConfiguration: writerCfg
|
||||
) { url in
|
||||
guard let rel = try? url.relativeChildPath(to: contextDir) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let parent = try? url.deletingLastPathComponent().relativeChildPath(to: self.contextDir) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let items = entries[parent] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let include = items.contains { item in
|
||||
item.relativePath == rel
|
||||
}
|
||||
|
||||
guard include else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Archiver.ArchiveEntryInfo(
|
||||
pathOnHost: url,
|
||||
pathInArchive: URL(fileURLWithPath: rel))
|
||||
}
|
||||
|
||||
let hash = tarHash.compactMap { String(format: "%02x", $0) }.joined()
|
||||
let header = BuildTransfer(
|
||||
id: packet.id,
|
||||
source: tarURL.path,
|
||||
complete: false,
|
||||
isDir: false,
|
||||
metadata: [
|
||||
"os": "linux",
|
||||
"stage": "fssync",
|
||||
"mode": "tar",
|
||||
"hash": hash,
|
||||
]
|
||||
)
|
||||
var resp = ClientStream()
|
||||
resp.buildID = buildID
|
||||
resp.buildTransfer = header
|
||||
resp.packetType = .buildTransfer(header)
|
||||
sender.yield(resp)
|
||||
|
||||
for try await chunk in try tarURL.bufferedCopyReader() {
|
||||
let part = BuildTransfer(
|
||||
id: packet.id,
|
||||
source: tarURL.path,
|
||||
complete: false,
|
||||
isDir: false,
|
||||
metadata: [
|
||||
"os": "linux",
|
||||
"stage": "fssync",
|
||||
"mode": "tar",
|
||||
],
|
||||
data: chunk
|
||||
)
|
||||
var resp = ClientStream()
|
||||
resp.buildID = buildID
|
||||
resp.buildTransfer = part
|
||||
resp.packetType = .buildTransfer(part)
|
||||
sender.yield(resp)
|
||||
}
|
||||
|
||||
let done = BuildTransfer(
|
||||
id: packet.id,
|
||||
source: tarURL.path,
|
||||
complete: true,
|
||||
isDir: false,
|
||||
metadata: [
|
||||
"os": "linux",
|
||||
"stage": "fssync",
|
||||
"mode": "tar",
|
||||
],
|
||||
data: Data()
|
||||
)
|
||||
|
||||
var finalResp = ClientStream()
|
||||
finalResp.buildID = buildID
|
||||
finalResp.buildTransfer = done
|
||||
finalResp.packetType = .buildTransfer(done)
|
||||
sender.yield(finalResp)
|
||||
}
|
||||
|
||||
func walk(root: URL, includePatterns: [String]) throws -> [URL] {
|
||||
let globber = Globber(root)
|
||||
|
||||
for p in includePatterns {
|
||||
try globber.match(p)
|
||||
}
|
||||
return Array(globber.results)
|
||||
}
|
||||
|
||||
private func processDirectory(
|
||||
_ currentDir: String,
|
||||
inputEntries: [String: Set<DirEntry>],
|
||||
processedPaths: inout [String]
|
||||
) throws {
|
||||
guard let entries = inputEntries[currentDir] else {
|
||||
return
|
||||
}
|
||||
|
||||
// Sort purely by lexicographical order of relativePath
|
||||
let sortedEntries = entries.sorted { $0.relativePath < $1.relativePath }
|
||||
|
||||
for entry in sortedEntries {
|
||||
processedPaths.append(entry.relativePath)
|
||||
|
||||
if entry.isDirectory {
|
||||
try processDirectory(
|
||||
entry.relativePath,
|
||||
inputEntries: inputEntries,
|
||||
processedPaths: &processedPaths
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FileInfo: Codable {
|
||||
let name: String
|
||||
let modTime: String
|
||||
let mode: UInt32
|
||||
let size: UInt64
|
||||
let isDir: Bool
|
||||
let uid: UInt32
|
||||
let gid: UInt32
|
||||
let target: String
|
||||
|
||||
init(path: URL, contextDir: URL) throws {
|
||||
if path.isSymlink {
|
||||
let target: URL = path.resolvingSymlinksInPath()
|
||||
if contextDir.parentOf(target) {
|
||||
self.target = target.relativePathFrom(from: path)
|
||||
} else {
|
||||
self.target = target.cleanPath
|
||||
}
|
||||
} else {
|
||||
self.target = ""
|
||||
}
|
||||
|
||||
self.name = try path.relativeChildPath(to: contextDir)
|
||||
self.modTime = try path.modTime()
|
||||
self.mode = try path.mode()
|
||||
self.size = try path.size()
|
||||
self.isDir = path.hasDirectoryPath
|
||||
self.uid = 0
|
||||
self.gid = 0
|
||||
}
|
||||
}
|
||||
|
||||
enum FSSyncMethod: String {
|
||||
case read = "Read"
|
||||
case info = "Info"
|
||||
case walk = "Walk"
|
||||
|
||||
init(_ method: String) throws {
|
||||
switch method {
|
||||
case "Read":
|
||||
self = .read
|
||||
case "Info":
|
||||
self = .info
|
||||
case "Walk":
|
||||
self = .walk
|
||||
default:
|
||||
throw Error.unknownMethod(method)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension BuildFSSync {
|
||||
enum Error: Swift.Error, CustomStringConvertible, Equatable {
|
||||
case buildTransferMissing
|
||||
case methodMissing
|
||||
case unknownMethod(String)
|
||||
case contextNotFound(String)
|
||||
case contextIsNotDirectory(String)
|
||||
case couldNotDetermineFileSize(String)
|
||||
case couldNotDetermineModTime(String)
|
||||
case couldNotDetermineFileMode(String)
|
||||
case invalidOffsetSizeForFile(String, UInt64, Int)
|
||||
case couldNotDetermineUID(String)
|
||||
case couldNotDetermineGID(String)
|
||||
case pathIsNotChild(String, String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .buildTransferMissing:
|
||||
return "buildTransfer field missing in packet"
|
||||
case .methodMissing:
|
||||
return "method is missing in request"
|
||||
case .unknownMethod(let m):
|
||||
return "unknown content-store method \(m)"
|
||||
case .contextNotFound(let path):
|
||||
return "context dir \(path) not found"
|
||||
case .contextIsNotDirectory(let path):
|
||||
return "context \(path) not a directory"
|
||||
case .couldNotDetermineFileSize(let path):
|
||||
return "could not determine size of file \(path)"
|
||||
case .couldNotDetermineModTime(let path):
|
||||
return "could not determine last modified time of \(path)"
|
||||
case .couldNotDetermineFileMode(let path):
|
||||
return "could not determine posix permissions (FileMode) of \(path)"
|
||||
case .invalidOffsetSizeForFile(let digest, let offset, let size):
|
||||
return "invalid request for file: \(digest) with offset: \(offset) size: \(size)"
|
||||
case .couldNotDetermineUID(let path):
|
||||
return "could not determine UID of file at path: \(path)"
|
||||
case .couldNotDetermineGID(let path):
|
||||
return "could not determine GID of file at path: \(path)"
|
||||
case .pathIsNotChild(let path, let parent):
|
||||
return "\(path) is not a child of \(parent)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension BuildTransfer {
|
||||
fileprivate init(id: String, source: String, complete: Bool, isDir: Bool, metadata: [String: String], data: Data? = nil) {
|
||||
self.init()
|
||||
self.id = id
|
||||
self.source = source
|
||||
self.direction = .outof
|
||||
self.complete = complete
|
||||
self.metadata = metadata
|
||||
self.isDirectory = isDir
|
||||
if let data {
|
||||
self.data = data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension URL {
|
||||
fileprivate func size() throws -> UInt64 {
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
|
||||
if let size = attrs[FileAttributeKey.size] as? UInt64 {
|
||||
return size
|
||||
}
|
||||
throw BuildFSSync.Error.couldNotDetermineFileSize(self.cleanPath)
|
||||
}
|
||||
|
||||
fileprivate func modTime() throws -> String {
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
|
||||
if let date = attrs[FileAttributeKey.modificationDate] as? Date {
|
||||
return date.rfc3339()
|
||||
}
|
||||
throw BuildFSSync.Error.couldNotDetermineModTime(self.cleanPath)
|
||||
}
|
||||
|
||||
fileprivate func isDir() throws -> Bool {
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
|
||||
guard let t = attrs[.type] as? FileAttributeType, t == .typeDirectory else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fileprivate func mode() throws -> UInt32 {
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
|
||||
if let mode = attrs[FileAttributeKey.posixPermissions] as? NSNumber {
|
||||
return mode.uint32Value
|
||||
}
|
||||
throw BuildFSSync.Error.couldNotDetermineFileMode(self.cleanPath)
|
||||
}
|
||||
|
||||
fileprivate func uid() throws -> UInt32 {
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
|
||||
if let uid = attrs[.ownerAccountID] as? UInt32 {
|
||||
return uid
|
||||
}
|
||||
throw BuildFSSync.Error.couldNotDetermineUID(self.cleanPath)
|
||||
}
|
||||
|
||||
fileprivate func gid() throws -> UInt32 {
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
|
||||
if let gid = attrs[.groupOwnerAccountID] as? UInt32 {
|
||||
return gid
|
||||
}
|
||||
throw BuildFSSync.Error.couldNotDetermineGID(self.cleanPath)
|
||||
}
|
||||
|
||||
fileprivate func buildTransfer(
|
||||
id: String,
|
||||
contextDir: URL? = nil,
|
||||
complete: Bool = false,
|
||||
data: Data = Data()
|
||||
) throws -> BuildTransfer {
|
||||
let p = try {
|
||||
if let contextDir { return try self.relativeChildPath(to: contextDir) }
|
||||
return self.cleanPath
|
||||
}()
|
||||
return BuildTransfer(
|
||||
id: id,
|
||||
source: String(p),
|
||||
complete: complete,
|
||||
isDir: try self.isDir(),
|
||||
metadata: [
|
||||
"os": "linux",
|
||||
"stage": "fssync",
|
||||
"mode": String(try self.mode()),
|
||||
"size": String(try self.size()),
|
||||
"modified_at": try self.modTime(),
|
||||
"uid": String(try self.uid()),
|
||||
"gid": String(try self.gid()),
|
||||
],
|
||||
data: data
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Date {
|
||||
fileprivate func rfc3339() -> String {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
|
||||
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) // Adjust if necessary
|
||||
|
||||
return dateFormatter.string(from: self)
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
var cleanPathComponent: String {
|
||||
let trimmed = self.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
if let clean = trimmed.removingPercentEncoding {
|
||||
return clean
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-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 Foundation
|
||||
import Logging
|
||||
|
||||
public struct BuildFile {
|
||||
/// Tries to resolve either a Dockerfile or Containerfile relative to contextDir.
|
||||
/// Checks for Dockerfile, then falls back to Containerfile.
|
||||
public static func resolvePath(contextDir: String, log: Logger? = nil) throws -> String? {
|
||||
// Check for Dockerfile then Containerfile in context directory
|
||||
let dockerfilePath = URL(filePath: contextDir).appendingPathComponent("Dockerfile").path
|
||||
let containerfilePath = URL(filePath: contextDir).appendingPathComponent("Containerfile").path
|
||||
|
||||
let dockerfileExists = FileManager.default.fileExists(atPath: dockerfilePath)
|
||||
let containerfileExists = FileManager.default.fileExists(atPath: containerfilePath)
|
||||
|
||||
if dockerfileExists && containerfileExists {
|
||||
log?.info("Detected both Dockerfile and Containerfile, choosing Dockerfile")
|
||||
return dockerfilePath
|
||||
}
|
||||
|
||||
if dockerfileExists {
|
||||
return dockerfilePath
|
||||
}
|
||||
|
||||
if containerfileExists {
|
||||
return containerfilePath
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-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 ContainerAPIClient
|
||||
import ContainerPersistence
|
||||
import Containerization
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
import GRPCCore
|
||||
import Logging
|
||||
import TerminalProgress
|
||||
|
||||
struct BuildImageResolver: BuildPipelineHandler {
|
||||
let contentStore: ContentStore
|
||||
let quiet: Bool
|
||||
let output: FileHandle
|
||||
let pull: Bool
|
||||
let containerSystemConfig: ContainerSystemConfig
|
||||
|
||||
public init(_ contentStore: ContentStore, quiet: Bool = false, output: FileHandle = FileHandle.standardError, pull: Bool = false, containerSystemConfig: ContainerSystemConfig)
|
||||
throws
|
||||
{
|
||||
self.contentStore = contentStore
|
||||
self.quiet = quiet
|
||||
self.output = output
|
||||
self.pull = pull
|
||||
self.containerSystemConfig = containerSystemConfig
|
||||
}
|
||||
|
||||
func accept(_ packet: ServerStream) throws -> Bool {
|
||||
guard let imageTransfer = packet.getImageTransfer() else {
|
||||
return false
|
||||
}
|
||||
guard imageTransfer.stage() == "resolver" else {
|
||||
return false
|
||||
}
|
||||
guard imageTransfer.method() == "/resolve" else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws {
|
||||
guard let imageTransfer = packet.getImageTransfer() else {
|
||||
throw Error.imageTransferMissing
|
||||
}
|
||||
guard let ref = imageTransfer.ref() else {
|
||||
throw Error.tagMissing
|
||||
}
|
||||
|
||||
guard let platform = try imageTransfer.platform() else {
|
||||
throw Error.platformMissing
|
||||
}
|
||||
|
||||
let img = try await {
|
||||
let progressConfig = try ProgressConfig(
|
||||
terminal: self.output,
|
||||
description: "Pulling \(ref)",
|
||||
showPercent: true,
|
||||
showProgressBar: true,
|
||||
showSize: true,
|
||||
showSpeed: true,
|
||||
disableProgressUpdates: self.quiet
|
||||
)
|
||||
let progress = ProgressBar(config: progressConfig)
|
||||
defer { progress.finish() }
|
||||
progress.start()
|
||||
|
||||
if self.pull {
|
||||
return try await ClientImage.pull(reference: ref, platform: platform, containerSystemConfig: containerSystemConfig, progressUpdate: progress.handler)
|
||||
}
|
||||
// Use fetch() which checks cache first, then pulls if needed
|
||||
return try await ClientImage.fetch(reference: ref, platform: platform, containerSystemConfig: containerSystemConfig, progressUpdate: progress.handler)
|
||||
}()
|
||||
|
||||
let index: Index = try await img.index()
|
||||
let buildID = packet.buildID
|
||||
let platforms = index.manifests.compactMap { $0.platform }
|
||||
for pl in platforms {
|
||||
if pl == platform {
|
||||
let manifest = try await img.manifest(for: pl)
|
||||
guard let ociImage: ContainerizationOCI.Image = try await self.contentStore.get(digest: manifest.config.digest) else {
|
||||
continue
|
||||
}
|
||||
let enc = JSONEncoder()
|
||||
let data = try enc.encode(ociImage)
|
||||
let transfer = try ImageTransfer(
|
||||
id: imageTransfer.id,
|
||||
digest: img.descriptor.digest,
|
||||
ref: ref,
|
||||
platform: platform.description,
|
||||
data: data
|
||||
)
|
||||
var response = ClientStream()
|
||||
response.buildID = buildID
|
||||
response.imageTransfer = transfer
|
||||
response.packetType = .imageTransfer(transfer)
|
||||
sender.yield(response)
|
||||
return
|
||||
}
|
||||
}
|
||||
throw Error.unknownPlatformForImage(platform.description, ref)
|
||||
}
|
||||
}
|
||||
|
||||
extension ImageTransfer {
|
||||
fileprivate init(id: String, digest: String, ref: String, platform: String, data: Data) throws {
|
||||
self.init()
|
||||
self.id = id
|
||||
self.tag = digest
|
||||
self.metadata = [
|
||||
"os": "linux",
|
||||
"stage": "resolver",
|
||||
"method": "/resolve",
|
||||
"ref": ref,
|
||||
"platform": platform,
|
||||
]
|
||||
self.complete = true
|
||||
self.direction = .into
|
||||
self.data = data
|
||||
}
|
||||
}
|
||||
|
||||
extension BuildImageResolver {
|
||||
enum Error: Swift.Error, CustomStringConvertible {
|
||||
case imageTransferMissing
|
||||
case tagMissing
|
||||
case platformMissing
|
||||
case imageNameMissing
|
||||
case imageTagMissing
|
||||
case imageNotFound
|
||||
case indexDigestMissing(String)
|
||||
case unknownRegistry(String)
|
||||
case digestIsNotIndex(String)
|
||||
case digestIsNotManifest(String)
|
||||
case unknownPlatformForImage(String, String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .imageTransferMissing:
|
||||
return "imageTransfer is missing"
|
||||
case .tagMissing:
|
||||
return "tag parameter missing in metadata"
|
||||
case .platformMissing:
|
||||
return "platform parameter missing in metadata"
|
||||
case .imageNameMissing:
|
||||
return "image name missing in $ref parameter"
|
||||
case .imageTagMissing:
|
||||
return "image tag missing in $ref parameter"
|
||||
case .imageNotFound:
|
||||
return "image not found"
|
||||
case .indexDigestMissing(let ref):
|
||||
return "index digest is missing for image: \(ref)"
|
||||
case .unknownRegistry(let registry):
|
||||
return "registry \(registry) is unknown"
|
||||
case .digestIsNotIndex(let digest):
|
||||
return "digest \(digest) is not a descriptor to an index"
|
||||
case .digestIsNotManifest(let digest):
|
||||
return "digest \(digest) is not a descriptor to a manifest"
|
||||
case .unknownPlatformForImage(let platform, let ref):
|
||||
return "platform \(platform) for image \(ref) not found"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-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 Foundation
|
||||
import GRPCCore
|
||||
import NIO
|
||||
|
||||
protocol BuildPipelineHandler: Sendable {
|
||||
func accept(_ packet: ServerStream) throws -> Bool
|
||||
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws
|
||||
}
|
||||
|
||||
public actor BuildPipeline {
|
||||
let handlers: [BuildPipelineHandler]
|
||||
public init(_ config: Builder.BuildConfig) async throws {
|
||||
self.handlers =
|
||||
[
|
||||
try BuildFSSync(URL(filePath: config.contextDir)),
|
||||
try BuildRemoteContentProxy(config.contentStore),
|
||||
try BuildImageResolver(
|
||||
config.contentStore,
|
||||
quiet: config.quiet,
|
||||
output: config.terminal?.handle ?? FileHandle.standardError,
|
||||
pull: config.pull,
|
||||
containerSystemConfig: config.containerSystemConfig
|
||||
),
|
||||
try BuildStdio(quiet: config.quiet, output: config.terminal?.handle ?? FileHandle.standardError),
|
||||
]
|
||||
}
|
||||
|
||||
public func run<S: AsyncSequence & Sendable>(
|
||||
sender: AsyncStream<ClientStream>.Continuation,
|
||||
receiver: S
|
||||
) async throws where S.Element == ServerStream {
|
||||
defer { sender.finish() }
|
||||
try await untilFirstError { group in
|
||||
for try await packet in receiver {
|
||||
try Task.checkCancellation()
|
||||
for handler in self.handlers {
|
||||
try Task.checkCancellation()
|
||||
guard try handler.accept(packet) else {
|
||||
continue
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
try await handler.handle(sender, packet)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// untilFirstError() throws when any one of its submitted tasks fail.
|
||||
/// This is useful for asynchronous packet processing scenarios which
|
||||
/// have the following 3 requirements:
|
||||
/// - the packet should be processed without blocking I/O
|
||||
/// - the packet stream is never-ending
|
||||
/// - when the first task fails, the error needs to be propagated to the caller
|
||||
///
|
||||
/// Usage:
|
||||
///
|
||||
/// ```
|
||||
/// try await untilFirstError { group in
|
||||
/// for try await packet in receiver {
|
||||
/// group.addTask {
|
||||
/// try await handler.handle(sender, packet)
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
///
|
||||
/// WithThrowingTaskGroup cannot accomplish this because it
|
||||
/// doesn't provide a mechanism to exit when one of the tasks fail
|
||||
/// before all the tasks have been added. i.e. it is more suitable for
|
||||
/// tasks that are limited. Here's a sample code where withThrowingTaskGroup
|
||||
/// doesn't solve the problem:
|
||||
///
|
||||
/// ```
|
||||
/// withThrowingTaskGroup { group in
|
||||
/// for try await packet in receiver {
|
||||
/// group.addTask {
|
||||
/// /* process packet */
|
||||
/// }
|
||||
/// } /* this loop blocks forever waiting for more packets */
|
||||
/// try await group.next() /* this never gets called */
|
||||
/// }
|
||||
/// ```
|
||||
/// The above closure never returns even when a handler encounters an error
|
||||
/// because the blocking operation `try await group.next()` cannot be
|
||||
/// called while iterating over the receiver stream.
|
||||
private func untilFirstError(body: @Sendable @escaping (UntilFirstError) async throws -> Void) async throws {
|
||||
let group = try await UntilFirstError()
|
||||
var taskContinuation: AsyncStream<Task<(), Error>>.Continuation?
|
||||
let tasks = AsyncStream<Task<(), Error>> { continuation in
|
||||
taskContinuation = continuation
|
||||
}
|
||||
guard let taskContinuation else {
|
||||
throw NSError(
|
||||
domain: "untilFirstError",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "failed to initialize task continuation"])
|
||||
}
|
||||
defer { taskContinuation.finish() }
|
||||
let stream = AsyncStream<Error> { continuation in
|
||||
let processTasks = Task {
|
||||
let taskStream = await group.tasks()
|
||||
defer {
|
||||
continuation.finish()
|
||||
}
|
||||
for await item in taskStream {
|
||||
try Task.checkCancellation()
|
||||
let addedTask = Task {
|
||||
try Task.checkCancellation()
|
||||
do {
|
||||
try await item()
|
||||
} catch {
|
||||
continuation.yield(error)
|
||||
await group.continuation?.finish()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
taskContinuation.yield(addedTask)
|
||||
}
|
||||
}
|
||||
taskContinuation.yield(processTasks)
|
||||
|
||||
let mainTask = Task { @Sendable in
|
||||
defer {
|
||||
continuation.finish()
|
||||
processTasks.cancel()
|
||||
taskContinuation.finish()
|
||||
}
|
||||
do {
|
||||
try Task.checkCancellation()
|
||||
try await body(group)
|
||||
} catch {
|
||||
continuation.yield(error)
|
||||
await group.continuation?.finish()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
taskContinuation.yield(mainTask)
|
||||
}
|
||||
|
||||
// when the first handler fails, cancel all tasks and throw error
|
||||
for await item in stream {
|
||||
try Task.checkCancellation()
|
||||
Task {
|
||||
for await task in tasks {
|
||||
task.cancel()
|
||||
}
|
||||
}
|
||||
throw item
|
||||
}
|
||||
// if none of the handlers fail, wait for all subtasks to complete
|
||||
for await task in tasks {
|
||||
try Task.checkCancellation()
|
||||
try await task.value
|
||||
}
|
||||
}
|
||||
|
||||
private actor UntilFirstError {
|
||||
var stream: AsyncStream<@Sendable () async throws -> Void>?
|
||||
var continuation: AsyncStream<@Sendable () async throws -> Void>.Continuation?
|
||||
|
||||
init() async throws {
|
||||
self.stream = AsyncStream { cont in
|
||||
self.continuation = cont
|
||||
}
|
||||
guard let _ = continuation else {
|
||||
throw NSError()
|
||||
}
|
||||
}
|
||||
|
||||
func addTask(body: @Sendable @escaping () async throws -> Void) {
|
||||
if !Task.isCancelled {
|
||||
self.continuation?.yield(body)
|
||||
}
|
||||
}
|
||||
|
||||
func tasks() -> AsyncStream<@Sendable () async throws -> Void> {
|
||||
self.stream!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-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 ContainerAPIClient
|
||||
import Containerization
|
||||
import ContainerizationArchive
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
import GRPCCore
|
||||
|
||||
struct BuildRemoteContentProxy: BuildPipelineHandler {
|
||||
let local: ContentStore
|
||||
|
||||
public init(_ contentStore: ContentStore) throws {
|
||||
self.local = contentStore
|
||||
}
|
||||
|
||||
func accept(_ packet: ServerStream) throws -> Bool {
|
||||
guard let imageTransfer = packet.getImageTransfer() else {
|
||||
return false
|
||||
}
|
||||
guard imageTransfer.stage() == "content-store" else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws {
|
||||
guard let imageTransfer = packet.getImageTransfer() else {
|
||||
throw Error.imageTransferMissing
|
||||
}
|
||||
|
||||
guard let method = imageTransfer.method() else {
|
||||
throw Error.methodMissing
|
||||
}
|
||||
|
||||
switch try ContentStoreMethod(method) {
|
||||
case .info:
|
||||
try await self.info(sender, imageTransfer, packet.buildID)
|
||||
case .readerAt:
|
||||
try await self.readerAt(sender, imageTransfer, packet.buildID)
|
||||
default:
|
||||
throw Error.unknownMethod(method)
|
||||
}
|
||||
}
|
||||
|
||||
func info(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ImageTransfer, _ buildID: String) async throws {
|
||||
let descriptor = try await local.get(digest: packet.tag)
|
||||
let size = try descriptor?.size()
|
||||
let transfer = try ImageTransfer(
|
||||
id: packet.id,
|
||||
digest: packet.tag,
|
||||
method: ContentStoreMethod.info.rawValue,
|
||||
size: size
|
||||
)
|
||||
var response = ClientStream()
|
||||
response.buildID = buildID
|
||||
response.imageTransfer = transfer
|
||||
response.packetType = .imageTransfer(transfer)
|
||||
sender.yield(response)
|
||||
}
|
||||
|
||||
func readerAt(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ImageTransfer, _ buildID: String) async throws {
|
||||
let digest = packet.descriptor.digest
|
||||
let offset: UInt64 = packet.offset() ?? 0
|
||||
let size: Int = packet.len() ?? 0
|
||||
guard let descriptor = try await local.get(digest: digest) else {
|
||||
throw Error.contentMissing
|
||||
}
|
||||
if offset == 0 && size == 0 { // Metadata request
|
||||
var transfer = try ImageTransfer(
|
||||
id: packet.id,
|
||||
digest: packet.tag,
|
||||
method: ContentStoreMethod.readerAt.rawValue,
|
||||
size: descriptor.size(),
|
||||
data: Data()
|
||||
)
|
||||
transfer.complete = true
|
||||
var response = ClientStream()
|
||||
response.buildID = buildID
|
||||
response.imageTransfer = transfer
|
||||
response.packetType = .imageTransfer(transfer)
|
||||
sender.yield(response)
|
||||
return
|
||||
}
|
||||
guard let data = try descriptor.data(offset: offset, length: size) else {
|
||||
throw Error.invalidOffsetSizeForContent(packet.descriptor.digest, offset, size)
|
||||
}
|
||||
|
||||
let transfer = try ImageTransfer(
|
||||
id: packet.id,
|
||||
digest: packet.tag,
|
||||
method: ContentStoreMethod.readerAt.rawValue,
|
||||
size: UInt64(data.count),
|
||||
data: data
|
||||
)
|
||||
var response = ClientStream()
|
||||
response.buildID = buildID
|
||||
response.imageTransfer = transfer
|
||||
response.packetType = .imageTransfer(transfer)
|
||||
sender.yield(response)
|
||||
}
|
||||
|
||||
func delete(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ImageTransfer) async throws {
|
||||
throw NSError(domain: "RemoteContentProxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "unimplemented method \(ContentStoreMethod.delete)"])
|
||||
}
|
||||
|
||||
func update(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ImageTransfer) async throws {
|
||||
throw NSError(domain: "RemoteContentProxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "unimplemented method \(ContentStoreMethod.update)"])
|
||||
}
|
||||
|
||||
func walk(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ImageTransfer) async throws {
|
||||
throw NSError(domain: "RemoteContentProxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "unimplemented method \(ContentStoreMethod.walk)"])
|
||||
}
|
||||
|
||||
enum ContentStoreMethod: String {
|
||||
case info = "/containerd.services.content.v1.Content/Info"
|
||||
case readerAt = "/containerd.services.content.v1.Content/ReaderAt"
|
||||
case delete = "/containerd.services.content.v1.Content/Delete"
|
||||
case update = "/containerd.services.content.v1.Content/Update"
|
||||
case walk = "/containerd.services.content.v1.Content/Walk"
|
||||
|
||||
init(_ method: String) throws {
|
||||
guard let value = ContentStoreMethod(rawValue: method) else {
|
||||
throw Error.unknownMethod(method)
|
||||
}
|
||||
self = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ImageTransfer {
|
||||
fileprivate init(id: String, digest: String, method: String, size: UInt64? = nil, data: Data = Data()) throws {
|
||||
self.init()
|
||||
self.id = id
|
||||
self.tag = digest
|
||||
self.metadata = [
|
||||
"os": "linux",
|
||||
"stage": "content-store",
|
||||
"method": method,
|
||||
]
|
||||
if let size {
|
||||
self.metadata["size"] = String(size)
|
||||
}
|
||||
self.complete = true
|
||||
self.direction = .into
|
||||
self.data = data
|
||||
}
|
||||
}
|
||||
|
||||
extension BuildRemoteContentProxy {
|
||||
enum Error: Swift.Error, CustomStringConvertible {
|
||||
case imageTransferMissing
|
||||
case methodMissing
|
||||
case contentMissing
|
||||
case unknownMethod(String)
|
||||
case invalidOffsetSizeForContent(String, UInt64, Int)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .imageTransferMissing:
|
||||
return "imageTransfer is missing"
|
||||
case .methodMissing:
|
||||
return "method is missing in request"
|
||||
case .contentMissing:
|
||||
return "content cannot be found"
|
||||
case .unknownMethod(let m):
|
||||
return "unknown content-store method \(m)"
|
||||
case .invalidOffsetSizeForContent(let digest, let offset, let size):
|
||||
return "invalid request for content: \(digest) with offset: \(offset) size: \(size)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-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 ContainerizationOS
|
||||
import Foundation
|
||||
import GRPCCore
|
||||
import NIO
|
||||
|
||||
actor BuildStdio: BuildPipelineHandler {
|
||||
public let quiet: Bool
|
||||
public let handle: FileHandle
|
||||
|
||||
init(quiet: Bool = false, output: FileHandle = FileHandle.standardError) throws {
|
||||
self.quiet = quiet
|
||||
self.handle = output
|
||||
}
|
||||
|
||||
nonisolated func accept(_ packet: ServerStream) throws -> Bool {
|
||||
guard let _ = packet.getIO() else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws {
|
||||
guard !quiet else {
|
||||
return
|
||||
}
|
||||
guard let io = packet.getIO() else {
|
||||
throw Error.ioMissing
|
||||
}
|
||||
if let cmdString = try TerminalCommand().json() {
|
||||
var response = ClientStream()
|
||||
response.buildID = packet.buildID
|
||||
response.command = .init()
|
||||
response.command.id = packet.buildID
|
||||
response.command.command = cmdString
|
||||
sender.yield(response)
|
||||
}
|
||||
handle.write(io.data)
|
||||
}
|
||||
}
|
||||
|
||||
extension BuildStdio {
|
||||
enum Error: Swift.Error, CustomStringConvertible {
|
||||
case ioMissing
|
||||
case invalidContinuation
|
||||
var description: String {
|
||||
switch self {
|
||||
case .ioMissing:
|
||||
return "io field missing in packet"
|
||||
case .invalidContinuation:
|
||||
return "continuation could not created"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,433 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-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 ContainerAPIClient
|
||||
import ContainerPersistence
|
||||
import Containerization
|
||||
import ContainerizationOCI
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import GRPCCore
|
||||
import GRPCNIOTransportHTTP2
|
||||
import Logging
|
||||
import NIO
|
||||
import NIOPosix
|
||||
|
||||
public struct Builder: Sendable {
|
||||
public static let builderContainerId = "buildkit"
|
||||
|
||||
let client: Com_Apple_Container_Build_V1_Builder.Client<HTTP2ClientTransport.WrappedChannel>
|
||||
let grpcClient: GRPCClient<HTTP2ClientTransport.WrappedChannel>
|
||||
let group: EventLoopGroup
|
||||
let builderShimSocket: FileHandle
|
||||
let clientTask: Task<Void, any Swift.Error>
|
||||
let logger: Logger
|
||||
|
||||
public init(socket: FileHandle, group: EventLoopGroup, logger: Logger) async throws {
|
||||
try socket.setSendBufSize(4 << 20)
|
||||
try socket.setRecvBufSize(2 << 20)
|
||||
|
||||
let transport = try await HTTP2ClientTransport.WrappedChannel.wrapping(
|
||||
config: .defaults,
|
||||
serviceConfig: .init()
|
||||
) { configure in
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
ClientBootstrap(group: group)
|
||||
.channelInitializer { channel in
|
||||
configure(channel).map { configured in
|
||||
continuation.resume(returning: configured)
|
||||
}
|
||||
}
|
||||
.withConnectedSocket(socket.fileDescriptor)
|
||||
.whenFailure { error in
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let grpcClient = GRPCClient(transport: transport)
|
||||
self.grpcClient = grpcClient
|
||||
self.client = Com_Apple_Container_Build_V1_Builder.Client(wrapping: grpcClient)
|
||||
self.group = group
|
||||
self.builderShimSocket = socket
|
||||
self.logger = logger
|
||||
|
||||
// Start the client connection loop in a background task
|
||||
self.clientTask = Task {
|
||||
do {
|
||||
try await grpcClient.runConnections()
|
||||
} catch is CancellationError {
|
||||
// Expected during graceful shutdown - re-throw
|
||||
throw CancellationError()
|
||||
} catch let error as RPCError where error.code == .unavailable {
|
||||
// Connection closed - this is expected when the container stops
|
||||
logger.debug("gRPC connection closed: \(error)")
|
||||
throw error
|
||||
} catch {
|
||||
// Log unexpected connection errors
|
||||
logger.error("gRPC client connection error: \(error)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func info() async throws -> InfoResponse {
|
||||
var opts = CallOptions.defaults
|
||||
opts.timeout = .seconds(30)
|
||||
return try await self.client.info(InfoRequest(), options: opts)
|
||||
}
|
||||
|
||||
// TODO
|
||||
// - Symlinks in build context dir
|
||||
// - cache-to, cache-from
|
||||
// - output (other than the default OCI image output, e.g., local, tar, Docker)
|
||||
public func build(_ config: BuildConfig) async throws {
|
||||
var continuation: AsyncStream<ClientStream>.Continuation?
|
||||
let reqStream = AsyncStream<ClientStream> { (cont: AsyncStream<ClientStream>.Continuation) in
|
||||
continuation = cont
|
||||
}
|
||||
guard let continuation else {
|
||||
throw Error.invalidContinuation
|
||||
}
|
||||
|
||||
defer {
|
||||
continuation.finish()
|
||||
}
|
||||
|
||||
if let terminal = config.terminal {
|
||||
Task {
|
||||
let winchHandler = AsyncSignalHandler.create(notify: [SIGWINCH])
|
||||
let setWinch = { (rows: UInt16, cols: UInt16) in
|
||||
var winch = ClientStream()
|
||||
winch.command = .init()
|
||||
if let cmdString = try TerminalCommand(rows: rows, cols: cols).json() {
|
||||
winch.command.command = cmdString
|
||||
continuation.yield(winch)
|
||||
}
|
||||
}
|
||||
let size = try terminal.size
|
||||
var width = size.width
|
||||
var height = size.height
|
||||
try setWinch(height, width)
|
||||
|
||||
for await _ in winchHandler.signals {
|
||||
let size = try terminal.size
|
||||
let cols = size.width
|
||||
let rows = size.height
|
||||
if cols != width || rows != height {
|
||||
width = cols
|
||||
height = rows
|
||||
try setWinch(height, width)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pipeline = try await BuildPipeline(config)
|
||||
do {
|
||||
try await self.client.performBuild(
|
||||
metadata: try Self.buildMetadata(config),
|
||||
options: .defaults,
|
||||
requestProducer: { writer in
|
||||
for await message in reqStream {
|
||||
try await writer.write(message)
|
||||
}
|
||||
},
|
||||
onResponse: { response in
|
||||
try await pipeline.run(sender: continuation, receiver: response.messages)
|
||||
}
|
||||
)
|
||||
} catch Error.buildComplete {
|
||||
self.grpcClient.beginGracefulShutdown()
|
||||
self.clientTask.cancel()
|
||||
try await group.shutdownGracefully()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
public struct BuildExport: Sendable {
|
||||
public let type: String
|
||||
public var destination: URL?
|
||||
public let additionalFields: [String: String]
|
||||
public let rawValue: String
|
||||
|
||||
public init(type: String, destination: URL?, additionalFields: [String: String], rawValue: String) {
|
||||
self.type = type
|
||||
self.destination = destination
|
||||
self.additionalFields = additionalFields
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
public init(from input: String) throws {
|
||||
var typeValue: String?
|
||||
var destinationValue: URL?
|
||||
var additionalFields: [String: String] = [:]
|
||||
|
||||
let pairs = input.components(separatedBy: ",")
|
||||
for pair in pairs {
|
||||
let parts = pair.components(separatedBy: "=")
|
||||
guard parts.count == 2 else { continue }
|
||||
|
||||
let key = parts[0].trimmingCharacters(in: .whitespaces)
|
||||
let value = parts[1].trimmingCharacters(in: .whitespaces)
|
||||
|
||||
switch key {
|
||||
case "type":
|
||||
typeValue = value
|
||||
case "dest":
|
||||
destinationValue = try Self.resolveDestination(dest: value)
|
||||
default:
|
||||
additionalFields[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
guard let type = typeValue else {
|
||||
throw Builder.Error.invalidExport(input, "type field is required")
|
||||
}
|
||||
|
||||
switch type {
|
||||
case "oci":
|
||||
break
|
||||
case "tar":
|
||||
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")
|
||||
}
|
||||
|
||||
self.init(type: type, destination: destinationValue, additionalFields: additionalFields, rawValue: input)
|
||||
}
|
||||
|
||||
public var stringValue: String {
|
||||
get throws {
|
||||
var components = ["type=\(type)"]
|
||||
|
||||
switch type {
|
||||
case "oci", "tar", "local":
|
||||
break // ignore destination
|
||||
default:
|
||||
throw Builder.Error.invalidExport(rawValue, "unsupported output type")
|
||||
}
|
||||
|
||||
for (key, value) in additionalFields {
|
||||
components.append("\(key)=\(value)")
|
||||
}
|
||||
|
||||
return components.joined(separator: ",")
|
||||
}
|
||||
}
|
||||
|
||||
static func resolveDestination(dest: String) throws -> URL {
|
||||
let destination = URL(fileURLWithPath: dest)
|
||||
let fileManager = FileManager.default
|
||||
|
||||
if fileManager.fileExists(atPath: destination.path) {
|
||||
let resourceValues = try destination.resourceValues(forKeys: [.isDirectoryKey])
|
||||
let isDir = resourceValues.isDirectory
|
||||
if isDir != nil && isDir == false {
|
||||
throw Builder.Error.invalidExport(dest, "dest path already exists")
|
||||
}
|
||||
|
||||
var finalDestination = destination.appendingPathComponent("out.tar")
|
||||
var index = 1
|
||||
while fileManager.fileExists(atPath: finalDestination.path) {
|
||||
let path = "out.tar.\(index)"
|
||||
finalDestination = destination.appendingPathComponent(path)
|
||||
index += 1
|
||||
}
|
||||
return finalDestination
|
||||
} else {
|
||||
let parentDirectory = destination.deletingLastPathComponent()
|
||||
try? fileManager.createDirectory(at: parentDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
}
|
||||
|
||||
return destination
|
||||
}
|
||||
}
|
||||
|
||||
public struct BuildConfig: Sendable {
|
||||
public let buildID: String
|
||||
public let contentStore: ContentStore
|
||||
public let buildArgs: [String]
|
||||
public let secrets: [String: Data]
|
||||
public let contextDir: String
|
||||
public let dockerfile: Data
|
||||
public let dockerignore: Data?
|
||||
public let labels: [String]
|
||||
public let noCache: Bool
|
||||
public let platforms: [Platform]
|
||||
public let terminal: Terminal?
|
||||
public let tags: [String]
|
||||
public let target: String
|
||||
public let quiet: Bool
|
||||
public let exports: [BuildExport]
|
||||
public let cacheIn: [String]
|
||||
public let cacheOut: [String]
|
||||
public let pull: Bool
|
||||
public let containerSystemConfig: ContainerSystemConfig
|
||||
|
||||
public init(
|
||||
buildID: String,
|
||||
contentStore: ContentStore,
|
||||
buildArgs: [String],
|
||||
secrets: [String: Data],
|
||||
contextDir: String,
|
||||
dockerfile: Data,
|
||||
dockerignore: Data?,
|
||||
labels: [String],
|
||||
noCache: Bool,
|
||||
platforms: [Platform],
|
||||
terminal: Terminal?,
|
||||
tags: [String],
|
||||
target: String,
|
||||
quiet: Bool,
|
||||
exports: [BuildExport],
|
||||
cacheIn: [String],
|
||||
cacheOut: [String],
|
||||
pull: Bool,
|
||||
containerSystemConfig: ContainerSystemConfig
|
||||
) {
|
||||
self.buildID = buildID
|
||||
self.contentStore = contentStore
|
||||
self.buildArgs = buildArgs
|
||||
self.secrets = secrets
|
||||
self.contextDir = contextDir
|
||||
self.dockerfile = dockerfile
|
||||
self.dockerignore = dockerignore
|
||||
self.labels = labels
|
||||
self.noCache = noCache
|
||||
self.platforms = platforms
|
||||
self.terminal = terminal
|
||||
self.tags = tags
|
||||
self.target = target
|
||||
self.quiet = quiet
|
||||
self.exports = exports
|
||||
self.cacheIn = cacheIn
|
||||
self.cacheOut = cacheOut
|
||||
self.pull = pull
|
||||
self.containerSystemConfig = containerSystemConfig
|
||||
}
|
||||
}
|
||||
|
||||
static func buildMetadata(_ config: BuildConfig) throws -> Metadata {
|
||||
var metadata = Metadata()
|
||||
metadata.addString(config.buildID, forKey: "build-id")
|
||||
metadata.addString(URL(filePath: config.contextDir).path(percentEncoded: false), forKey: "context")
|
||||
metadata.addString(config.dockerfile.base64EncodedString(), forKey: "dockerfile")
|
||||
metadata.addString(config.terminal != nil ? "tty" : "plain", forKey: "progress")
|
||||
metadata.addString(config.target, forKey: "target")
|
||||
|
||||
if let dockerignore = config.dockerignore {
|
||||
metadata.addString(dockerignore.base64EncodedString(), forKey: "dockerignore")
|
||||
}
|
||||
for tag in config.tags {
|
||||
metadata.addString(tag, forKey: "tag")
|
||||
}
|
||||
for platform in config.platforms {
|
||||
metadata.addString(platform.description, forKey: "platforms")
|
||||
}
|
||||
if config.noCache {
|
||||
metadata.addString("", forKey: "no-cache")
|
||||
}
|
||||
for label in config.labels {
|
||||
metadata.addString(label, forKey: "labels")
|
||||
}
|
||||
for buildArg in config.buildArgs {
|
||||
metadata.addString(buildArg, forKey: "build-args")
|
||||
}
|
||||
for (id, data) in config.secrets {
|
||||
metadata.addString(id + "=" + data.base64EncodedString(), forKey: "secrets")
|
||||
}
|
||||
for output in config.exports {
|
||||
metadata.addString(try output.stringValue, forKey: "outputs")
|
||||
}
|
||||
for cacheIn in config.cacheIn {
|
||||
metadata.addString(cacheIn, forKey: "cache-in")
|
||||
}
|
||||
for cacheOut in config.cacheOut {
|
||||
metadata.addString(cacheOut, forKey: "cache-out")
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
}
|
||||
|
||||
extension Builder {
|
||||
enum Error: Swift.Error, CustomStringConvertible {
|
||||
case invalidContinuation
|
||||
case buildComplete
|
||||
case invalidExport(String, String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .invalidContinuation:
|
||||
return "continuation could not created"
|
||||
case .buildComplete:
|
||||
return "build completed"
|
||||
case .invalidExport(let exp, let reason):
|
||||
return "export entry \(exp) is invalid: \(reason)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FileHandle {
|
||||
@discardableResult
|
||||
func setSendBufSize(_ bytes: Int) throws -> Int {
|
||||
try setSockOpt(
|
||||
level: SOL_SOCKET,
|
||||
name: SO_SNDBUF,
|
||||
value: bytes)
|
||||
return bytes
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setRecvBufSize(_ bytes: Int) throws -> Int {
|
||||
try setSockOpt(
|
||||
level: SOL_SOCKET,
|
||||
name: SO_RCVBUF,
|
||||
value: bytes)
|
||||
return bytes
|
||||
}
|
||||
|
||||
private func setSockOpt(level: Int32, name: Int32, value: Int) throws {
|
||||
var v = Int32(value)
|
||||
let res = withUnsafePointer(to: &v) { ptr -> Int32 in
|
||||
ptr.withMemoryRebound(
|
||||
to: UInt8.self,
|
||||
capacity: MemoryLayout<Int32>.size
|
||||
) { raw in
|
||||
#if canImport(Darwin)
|
||||
return setsockopt(
|
||||
self.fileDescriptor,
|
||||
level, name,
|
||||
raw,
|
||||
socklen_t(MemoryLayout<Int32>.size))
|
||||
#else
|
||||
fatalError("unsupported platform")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
if res == -1 {
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EPERM)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-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 Foundation
|
||||
|
||||
public class Globber {
|
||||
let input: URL
|
||||
var results: Set<URL> = .init()
|
||||
|
||||
public init(_ input: URL) {
|
||||
self.input = input
|
||||
}
|
||||
|
||||
public func match(_ pattern: String) throws {
|
||||
let adjustedPattern =
|
||||
pattern
|
||||
.replacingOccurrences(of: #"^\./(?=.)"#, with: "", options: .regularExpression)
|
||||
.replacingOccurrences(of: "^\\.[/]?$", with: "*", options: .regularExpression)
|
||||
.replacingOccurrences(of: "\\*{2,}[/]", with: "*/**/", options: .regularExpression)
|
||||
.replacingOccurrences(of: "[/]\\*{2,}([^/])", with: "/**/*$1", options: .regularExpression)
|
||||
.replacingOccurrences(of: "^\\*{2,}([^/])", with: "**/*$1", options: .regularExpression)
|
||||
|
||||
for child in input.children {
|
||||
try self.match(input: child, components: adjustedPattern.split(separator: "/").map(String.init))
|
||||
}
|
||||
}
|
||||
|
||||
private func match(input: URL, components: [String]) throws {
|
||||
if components.isEmpty {
|
||||
var dir = input.standardizedFileURL
|
||||
|
||||
while dir != self.input.standardizedFileURL {
|
||||
results.insert(dir)
|
||||
guard dir.pathComponents.count > 1 else { break }
|
||||
dir.deleteLastPathComponent()
|
||||
}
|
||||
return input.childrenRecursive.forEach { results.insert($0) }
|
||||
}
|
||||
|
||||
let head = components.first ?? ""
|
||||
let tail = components.tail
|
||||
|
||||
if head == "**" {
|
||||
var tail: [String] = tail
|
||||
while tail.first == "**" {
|
||||
tail = tail.tail
|
||||
}
|
||||
try self.match(input: input, components: tail)
|
||||
for child in input.children {
|
||||
try self.match(input: child, components: components)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if try glob(input.lastPathComponent, head) {
|
||||
try self.match(input: input, components: tail)
|
||||
|
||||
for child in input.children where try glob(child.lastPathComponent, tail.first ?? "") {
|
||||
try self.match(input: child, components: tail)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func glob(_ input: String, _ pattern: String) throws -> Bool {
|
||||
let regexPattern =
|
||||
"^"
|
||||
+ NSRegularExpression.escapedPattern(for: pattern)
|
||||
.replacingOccurrences(of: "\\*", with: "[^/]*")
|
||||
.replacingOccurrences(of: "\\?", with: "[^/]")
|
||||
.replacingOccurrences(of: "[\\^", with: "[^")
|
||||
.replacingOccurrences(of: "\\[", with: "[")
|
||||
.replacingOccurrences(of: "\\]", with: "]") + "$"
|
||||
|
||||
// validate the regex pattern created
|
||||
let _ = try Regex(regexPattern)
|
||||
return input.range(of: regexPattern, options: .regularExpression) != nil
|
||||
}
|
||||
}
|
||||
|
||||
extension URL {
|
||||
var children: [URL] {
|
||||
|
||||
(try? FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil))
|
||||
?? []
|
||||
}
|
||||
|
||||
var childrenRecursive: [URL] {
|
||||
var results: [URL] = []
|
||||
if let enumerator = FileManager.default.enumerator(
|
||||
at: self, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey])
|
||||
{
|
||||
while let child = enumerator.nextObject() as? URL {
|
||||
results.append(child)
|
||||
}
|
||||
}
|
||||
return [self] + results
|
||||
}
|
||||
}
|
||||
|
||||
extension [String] {
|
||||
var tail: [String] {
|
||||
if self.count <= 1 {
|
||||
return []
|
||||
}
|
||||
return Array(self.dropFirst())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-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 Foundation
|
||||
|
||||
struct TerminalCommand: Codable {
|
||||
let commandType: String
|
||||
let code: String
|
||||
let rows: UInt16
|
||||
let cols: UInt16
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case commandType = "command_type"
|
||||
case code
|
||||
case rows
|
||||
case cols
|
||||
}
|
||||
|
||||
init(rows: UInt16, cols: UInt16) {
|
||||
self.commandType = "terminal"
|
||||
self.code = "winch"
|
||||
self.rows = rows
|
||||
self.cols = cols
|
||||
}
|
||||
|
||||
init() {
|
||||
self.commandType = "terminal"
|
||||
self.code = "ack"
|
||||
self.rows = 0
|
||||
self.cols = 0
|
||||
}
|
||||
|
||||
func json() throws -> String? {
|
||||
let encoder = JSONEncoder()
|
||||
let data = try encoder.encode(self)
|
||||
return data.base64EncodedString().trimmingCharacters(in: CharacterSet(charactersIn: "="))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-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 Foundation
|
||||
|
||||
extension String {
|
||||
fileprivate var fs_cleaned: String {
|
||||
var value = self
|
||||
|
||||
if value.hasPrefix("file://") {
|
||||
value.removeFirst("file://".count)
|
||||
}
|
||||
|
||||
if value.count > 1 && value.last == "/" {
|
||||
value.removeLast()
|
||||
}
|
||||
|
||||
return value.removingPercentEncoding ?? value
|
||||
}
|
||||
|
||||
fileprivate var fs_components: [String] {
|
||||
var parts: [String] = []
|
||||
for segment in self.split(separator: "/", omittingEmptySubsequences: true) {
|
||||
switch segment {
|
||||
case ".":
|
||||
continue
|
||||
case "..":
|
||||
if !parts.isEmpty { parts.removeLast() }
|
||||
default:
|
||||
parts.append(String(segment))
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
fileprivate var fs_isAbsolute: Bool { first == "/" }
|
||||
}
|
||||
|
||||
extension URL {
|
||||
var cleanPath: String {
|
||||
self.path.fs_cleaned
|
||||
}
|
||||
|
||||
func parentOf(_ url: URL) -> Bool {
|
||||
let parentPath = self.absoluteURL.cleanPath
|
||||
let childPath = url.absoluteURL.cleanPath
|
||||
|
||||
guard parentPath.fs_isAbsolute else {
|
||||
return true
|
||||
}
|
||||
|
||||
let parentParts = parentPath.fs_components
|
||||
let childParts = childPath.fs_components
|
||||
|
||||
guard parentParts.count <= childParts.count else { return false }
|
||||
return zip(parentParts, childParts).allSatisfy { $0 == $1 }
|
||||
}
|
||||
|
||||
func relativeChildPath(to context: URL) throws -> String {
|
||||
guard context.parentOf(self) else {
|
||||
throw BuildFSSync.Error.pathIsNotChild(cleanPath, context.cleanPath)
|
||||
}
|
||||
|
||||
let ctxParts = context.cleanPath.fs_components
|
||||
let selfParts = cleanPath.fs_components
|
||||
|
||||
return selfParts.dropFirst(ctxParts.count).joined(separator: "/")
|
||||
}
|
||||
|
||||
func relativePathFrom(from base: URL) -> String {
|
||||
let destParts = cleanPath.fs_components
|
||||
let baseParts = base.cleanPath.fs_components
|
||||
|
||||
let common = zip(destParts, baseParts).prefix { $0 == $1 }.count
|
||||
guard common > 0 else { return cleanPath }
|
||||
|
||||
let ups = Array(repeating: "..", count: baseParts.count - common)
|
||||
let remainder = destParts.dropFirst(common)
|
||||
return (ups + remainder).joined(separator: "/")
|
||||
}
|
||||
|
||||
func zeroCopyReader(
|
||||
chunk: Int = 1024 * 1024,
|
||||
buffer: AsyncStream<Data>.Continuation.BufferingPolicy = .unbounded
|
||||
) throws -> AsyncStream<Data> {
|
||||
|
||||
let path = self.cleanPath
|
||||
let fd = open(path, O_RDONLY | O_NONBLOCK)
|
||||
guard fd >= 0 else { throw POSIXError.fromErrno() }
|
||||
|
||||
let channel = DispatchIO(
|
||||
type: .stream,
|
||||
fileDescriptor: fd,
|
||||
queue: .global(qos: .userInitiated)
|
||||
) { errno in
|
||||
close(fd)
|
||||
}
|
||||
|
||||
channel.setLimit(highWater: chunk)
|
||||
return AsyncStream(bufferingPolicy: buffer) { continuation in
|
||||
|
||||
channel.read(
|
||||
offset: 0, length: Int.max,
|
||||
queue: .global(qos: .userInitiated)
|
||||
) { done, ddata, err in
|
||||
if err != 0 {
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
|
||||
if let ddata, ddata.count > -1 {
|
||||
let data = Data(ddata)
|
||||
|
||||
switch continuation.yield(data) {
|
||||
case .terminated:
|
||||
channel.close(flags: .stop)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
if done {
|
||||
channel.close(flags: .stop)
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func bufferedCopyReader(chunkSize: Int = 4 * 1024 * 1024) throws -> BufferedCopyReader {
|
||||
try BufferedCopyReader(url: self, chunkSize: chunkSize)
|
||||
}
|
||||
}
|
||||
|
||||
/// A synchronous buffered reader that reads one chunk at a time from a file
|
||||
/// Uses a configurable buffer size (default 4MB) and only reads when nextChunk() is called
|
||||
/// Implements AsyncSequence for use with `for await` loops
|
||||
public final class BufferedCopyReader: AsyncSequence {
|
||||
public typealias Element = Data
|
||||
public typealias AsyncIterator = BufferedCopyReaderIterator
|
||||
|
||||
private let inputStream: InputStream
|
||||
private let chunkSize: Int
|
||||
private var isFinished: Bool = false
|
||||
private let reusableBuffer: UnsafeMutablePointer<UInt8>
|
||||
|
||||
/// Initialize a buffered copy reader for the given URL
|
||||
/// - Parameters:
|
||||
/// - url: The file URL to read from
|
||||
/// - chunkSize: Size of each chunk to read (default: 4MB)
|
||||
public init(url: URL, chunkSize: Int = 4 * 1024 * 1024) throws {
|
||||
guard let stream = InputStream(url: url) else {
|
||||
throw CocoaError(.fileReadNoSuchFile)
|
||||
}
|
||||
self.inputStream = stream
|
||||
self.chunkSize = chunkSize
|
||||
self.reusableBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: chunkSize)
|
||||
self.inputStream.open()
|
||||
}
|
||||
|
||||
deinit {
|
||||
inputStream.close()
|
||||
reusableBuffer.deallocate()
|
||||
}
|
||||
|
||||
/// Create an async iterator for this sequence
|
||||
public func makeAsyncIterator() -> BufferedCopyReaderIterator {
|
||||
BufferedCopyReaderIterator(reader: self)
|
||||
}
|
||||
|
||||
/// Read the next chunk of data from the file
|
||||
/// - Returns: Data chunk, or nil if end of file reached
|
||||
/// - Throws: Any file reading errors
|
||||
public func nextChunk() throws -> Data? {
|
||||
guard !isFinished else { return nil }
|
||||
|
||||
// Read directly into our reusable buffer
|
||||
let bytesRead = inputStream.read(reusableBuffer, maxLength: chunkSize)
|
||||
|
||||
// Check for errors
|
||||
if bytesRead < 0 {
|
||||
if let error = inputStream.streamError {
|
||||
throw error
|
||||
}
|
||||
throw CocoaError(.fileReadUnknown)
|
||||
}
|
||||
|
||||
// If we read no data, we've reached the end
|
||||
if bytesRead == 0 {
|
||||
isFinished = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we read less than the chunk size, this is the last chunk
|
||||
if bytesRead < chunkSize {
|
||||
isFinished = true
|
||||
}
|
||||
|
||||
// Create Data object only with the bytes actually read
|
||||
return Data(bytes: reusableBuffer, count: bytesRead)
|
||||
}
|
||||
|
||||
/// Check if the reader has finished reading the file
|
||||
public var hasFinished: Bool {
|
||||
isFinished
|
||||
}
|
||||
|
||||
/// Reset the reader to the beginning of the file
|
||||
/// Note: InputStream doesn't support seeking, so this recreates the stream
|
||||
/// - Throws: Any file opening errors
|
||||
public func reset() throws {
|
||||
inputStream.close()
|
||||
// Note: InputStream doesn't provide a way to get the original URL,
|
||||
// so reset functionality is limited. Consider removing this method
|
||||
// or storing the original URL if reset is needed.
|
||||
throw CocoaError(
|
||||
.fileReadUnsupportedScheme,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "reset not supported with InputStream-based implementation"
|
||||
])
|
||||
}
|
||||
|
||||
/// Get the current file offset
|
||||
/// Note: InputStream doesn't provide offset information
|
||||
/// - Returns: Current position in the file
|
||||
/// - Throws: Unsupported operation error
|
||||
public func currentOffset() throws -> UInt64 {
|
||||
throw CocoaError(
|
||||
.fileReadUnsupportedScheme,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "offset tracking not supported with InputStream-based implementation"
|
||||
])
|
||||
}
|
||||
|
||||
/// Seek to a specific offset in the file
|
||||
/// Note: InputStream doesn't support seeking
|
||||
/// - Parameter offset: The byte offset to seek to
|
||||
/// - Throws: Unsupported operation error
|
||||
public func seek(to offset: UInt64) throws {
|
||||
throw CocoaError(
|
||||
.fileReadUnsupportedScheme,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "seeking not supported with InputStream-based implementation"
|
||||
])
|
||||
}
|
||||
|
||||
/// Close the input stream explicitly (called automatically in deinit)
|
||||
public func close() {
|
||||
inputStream.close()
|
||||
isFinished = true
|
||||
}
|
||||
}
|
||||
|
||||
/// AsyncIteratorProtocol implementation for BufferedCopyReader
|
||||
public struct BufferedCopyReaderIterator: AsyncIteratorProtocol {
|
||||
public typealias Element = Data
|
||||
|
||||
private let reader: BufferedCopyReader
|
||||
|
||||
init(reader: BufferedCopyReader) {
|
||||
self.reader = reader
|
||||
}
|
||||
|
||||
/// Get the next chunk of data asynchronously
|
||||
/// - Returns: Next data chunk, or nil when finished
|
||||
/// - Throws: Any file reading errors
|
||||
public mutating func next() async throws -> Data? {
|
||||
// Yield control to allow other tasks to run, then read synchronously
|
||||
await Task.yield()
|
||||
return try reader.nextChunk()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user