diff --git a/Sources/tart/Commands/Clone.swift b/Sources/tart/Commands/Clone.swift index 887dbac..e56daeb 100644 --- a/Sources/tart/Commands/Clone.swift +++ b/Sources/tart/Commands/Clone.swift @@ -32,12 +32,17 @@ struct Clone: AsyncParsableCommand { } let sourceVM = try VMStorageHelper.open(sourceName) - let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress()) let tmpVMDir = try VMDirectory.temporary() try await withTaskCancellationHandler(operation: { + let lock = try FileLock(lockURL: Config().tartHomeDir) + try lock.lock() + + let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress()) try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC) try localStorage.move(newName, from: tmpVMDir) + + try lock.unlock() }, onCancel: { try? FileManager.default.removeItem(at: tmpVMDir.baseURL) }) diff --git a/Sources/tart/Config.swift b/Sources/tart/Config.swift index c7d0899..c6d6f8d 100644 --- a/Sources/tart/Config.swift +++ b/Sources/tart/Config.swift @@ -4,7 +4,7 @@ struct Config { let tartHomeDir: URL let tartCacheDir: URL - init() { + init() throws { var tartHomeDir: URL if let customTartHome = ProcessInfo.processInfo.environment["TART_HOME"] { @@ -17,6 +17,8 @@ struct Config { self.tartHomeDir = tartHomeDir tartCacheDir = tartHomeDir.appendingPathComponent("cache", isDirectory: true) + + try FileManager.default.createDirectory(at: tartCacheDir, withIntermediateDirectories: true) } static func jsonEncoder() -> JSONEncoder { diff --git a/Sources/tart/FileLock.swift b/Sources/tart/FileLock.swift new file mode 100644 index 0000000..aa288e9 --- /dev/null +++ b/Sources/tart/FileLock.swift @@ -0,0 +1,48 @@ +import Foundation +import System + +enum FileLockError: Error, Equatable { + case Failed(_ message: String) + case AlreadyLocked +} + +class FileLock { + let url: URL + let fd: Int32 + + init(lockURL: URL) throws { + url = lockURL + fd = open(lockURL.path, 0) + } + + deinit { + close(fd) + } + + func trylock() throws -> Bool { + try flockWrapper(LOCK_EX | LOCK_NB) + } + + func lock() throws { + _ = try flockWrapper(LOCK_EX) + } + + func unlock() throws { + _ = try flockWrapper(LOCK_UN) + } + + func flockWrapper(_ operation: Int32) throws -> Bool { + let ret = flock(fd, operation) + if ret != 0 { + let details = Errno(rawValue: CInt(errno)) + + if (operation & LOCK_NB) != 0 && details == .wouldBlock { + return false + } + + throw FileLockError.Failed("failed to lock \(url): \(details)") + } + + return true + } +} diff --git a/Sources/tart/IPSWCache.swift b/Sources/tart/IPSWCache.swift index 17ae752..1e9d9d4 100644 --- a/Sources/tart/IPSWCache.swift +++ b/Sources/tart/IPSWCache.swift @@ -5,7 +5,7 @@ class IPSWCache: PrunableStorage { let baseURL: URL init() throws { - baseURL = Config().tartCacheDir.appendingPathComponent("IPSWs", isDirectory: true) + baseURL = try Config().tartCacheDir.appendingPathComponent("IPSWs", isDirectory: true) try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true) } diff --git a/Sources/tart/VMStorageLocal.swift b/Sources/tart/VMStorageLocal.swift index 7126492..b94f942 100644 --- a/Sources/tart/VMStorageLocal.swift +++ b/Sources/tart/VMStorageLocal.swift @@ -1,7 +1,7 @@ import Foundation class VMStorageLocal { - let baseURL: URL = Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true) + let baseURL: URL = try! Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true) private func vmURL(_ name: String) -> URL { baseURL.appendingPathComponent(name, isDirectory: true) diff --git a/Sources/tart/VMStorageOCI.swift b/Sources/tart/VMStorageOCI.swift index b549104..3ab4e3e 100644 --- a/Sources/tart/VMStorageOCI.swift +++ b/Sources/tart/VMStorageOCI.swift @@ -1,12 +1,16 @@ import Foundation class VMStorageOCI: PrunableStorage { - let baseURL = Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true) + let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true) private func vmURL(_ name: RemoteName) -> URL { baseURL.appendingRemoteName(name) } + private func hostDirectoryURL(_ name: RemoteName) -> URL { + baseURL.appendingHost(name) + } + func exists(_ name: RemoteName) -> Bool { VMDirectory(baseURL: vmURL(name)).initialized } @@ -125,6 +129,24 @@ class VMStorageOCI: PrunableStorage { let digestName = RemoteName(host: name.host, namespace: name.namespace, reference: Reference(digest: Digest.hash(manifestData))) + // Ensure that host directory for given RemoteName exists in OCI storage + let hostDirectoryURL = hostDirectoryURL(digestName) + try FileManager.default.createDirectory(at: hostDirectoryURL, withIntermediateDirectories: true) + + // Acquire a lock on it to prevent concurrent pulls for a single host + let lock = try FileLock(lockURL: hostDirectoryURL) + + let sucessfullyLocked = try lock.trylock() + if !sucessfullyLocked { + print("waiting for lock...") + try lock.lock() + } + defer { try! lock.unlock() } + + if Task.isCancelled { + throw CancellationError() + } + if !exists(digestName) { let tmpVMDir = try VMDirectory.temporary() @@ -181,4 +203,8 @@ extension URL { return result } + + func appendingHost(_ name: RemoteName) -> URL { + self.appendingPathComponent(name.host, isDirectory: true) + } } diff --git a/Tests/TartTests/FileLockTests.swift b/Tests/TartTests/FileLockTests.swift new file mode 100644 index 0000000..4147bda --- /dev/null +++ b/Tests/TartTests/FileLockTests.swift @@ -0,0 +1,34 @@ +import XCTest +@testable import tart + +final class FileLockTests: XCTestCase { + func testSimple() throws { + // Create a temporary file that will be used as a lock + let url = temporaryFile() + + // Make sure this file can be locked and unlocked + let lock = try FileLock(lockURL: url) + try lock.lock() + try lock.unlock() + } + + func testDoubleLockResultsInError() throws { + // Create a temporary file that will be used as a lock + let url = temporaryFile() + + // Create two locks on a same file and ensure one of them fails + let firstLock = try FileLock(lockURL: url) + try firstLock.lock() + + let secondLock = try! FileLock(lockURL: url) + XCTAssertFalse(try secondLock.trylock()) + } + + private func temporaryFile() -> URL { + let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + + FileManager.default.createFile(atPath: url.path, contents: nil) + + return url + } +}