Compare commits

..
19 Commits
Author SHA1 Message Date
Fedor Korotkov f9001304c8 Fixed packaging 2022-09-14 10:06:22 -04:00
Nikolay Edigaryev 4e20ea8f72 tart run: introduce --net-bridged (#245)
* tart run: introduce --net-bridged

* tart.entitlements: add com.apple.vm.networking
2022-09-14 17:53:04 +04:00
Nikolay EdigaryevandPete Goldsmith 678ce0a55a Introduce "tart rename" command to rename VMs (#246)
* Introduce "tart rename" command to rename VMs

* Remove unused SystemConfiguration import

* Update Sources/tart/Commands/Rename.swift

Co-authored-by: Pete Goldsmith <peter.n.goldsmith@gmail.com>

Co-authored-by: Pete Goldsmith <peter.n.goldsmith@gmail.com>
2022-09-14 17:44:26 +04:00
Fedor Korotkov 8273ae66e1 Update Developer Certificates (#242)
* Update Developer Certificates

Now the signature will state `Cirrus Labs, Inc.` and not `Fedor Korotkov`.

* Updated identity
2022-09-13 09:36:35 -04:00
Fedor Korotkov 4a9316a377 [skip ci] Add sponsorship option (#241) 2022-09-13 16:30:26 +04:00
Fedor Korotkov 6321d547cf Improve available capacity checking (#240)
* Improve available capacity checking

* Fixed expression
2022-09-12 21:55:25 +04:00
Nikolay Edigaryev f241e21614 Self-hosted temporary directory (#238) 2022-09-12 20:56:52 +04:00
Nikolay Edigaryev 0eca923604 Document NAT subnet change procedure (#236) 2022-09-08 16:26:40 +04:00
Pete Goldsmith 87f29cc11f Clarify requirements for dir argument (#231) 2022-09-07 09:02:46 -04:00
Pete Goldsmith 90d1393137 Handle tilde in path for directory share (#233)
* Expand Tilde in path

* Expand tilde in disk paths
2022-09-07 08:15:29 -04:00
Nikolay Edigaryev 625d431d10 Revert "Improve RemoteName parser (#225)" (#230)
This reverts commit ae7018c31f.
2022-09-06 22:13:30 +04:00
Nikolay Edigaryev 4648e1aea1 tart clone: clone VM and generate MAC under a file lock (#215)
* tart clone: clone VM and generate MAC under a file lock

* Lock concurrent "tart pull"'s for the same host

* Config: ensure Tart's home and cache directories always exist
2022-09-06 21:33:51 +04:00
Fedor Korotkov 4b62b73015 Document Adopters (#229)
* Document early adopters

Related to #208

* Use HTML
2022-09-06 21:25:22 +04:00
Nikolay Edigaryev ae7018c31f Improve RemoteName parser (#225)
* Improve RemoteName parser

* Remove Parsing import

* Permit namespace components to contain separators, but no more than one

* Add testNoPathTraversal
2022-09-06 17:28:49 +04:00
Pete Goldsmith e54c89da0c Clarify if guest or host is unsupported (#228) 2022-09-06 09:23:41 -04:00
Nikolay Edigaryev 9a0ec3e6b0 Fix cache reclaimed bytes calculation (#223) 2022-09-01 16:12:48 -04:00
Fedor Korotkov 400f85a493 Check Tart Home for auto-pruning (#220)
Otherwise it's weird we check $TMP but prune $TART_HOME
2022-09-01 15:44:19 -04:00
Fedor Korotkov 0105280b5d Support plaintext auths from Docker config (#219) 2022-09-01 23:10:52 +04:00
Fedor Korotkov c063c5bdc2 Include version in installer (#218) 2022-09-01 19:42:50 +04:00
30 changed files with 414 additions and 55 deletions
+5 -3
View File
@@ -2,8 +2,10 @@
set -e
export VERSION="${CIRRUS_TAG:-0}"
mkdir -p .ci/pkg/
cp .build/arm64-apple-macosx/debug/tart .ci/pkg/
pkgbuild --root .ci/pkg --version "${CIRRUS_TAG:-0}" --install-location /usr/local/bin/ --identifier com.github.cirruslabs.tart --sign "Developer ID Installer: Fedor Korotkov (9M2P8L4D89)" ./dist/Tart.pkg
xcrun notarytool submit ./dist/Tart.pkg --keychain-profile "notarytool" --wait
xcrun stapler staple ./dist/Tart.pkg
pkgbuild --root .ci/pkg --version $VERSION --install-location /usr/local/bin/ --identifier com.github.cirruslabs.tart --sign "Developer ID Installer: Cirrus Labs, Inc. (9M2P8L4D89)" "./dist/Tart-$VERSION.pkg"
xcrun notarytool submit "./dist/Tart-$VERSION.pkg" --keychain-profile "notarytool" --wait
xcrun stapler staple "./dist/Tart-$VERSION.pkg"
+1 -1
View File
@@ -22,7 +22,7 @@ task:
macos_instance:
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
env:
MACOS_CERTIFICATE: ENCRYPTED[8a6930a8c1286e7e536ea41b7647ea40e99174ad15e9cfcc753754fea55a619b355415629dff515b54a8921643e314e5]
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
+1
View File
@@ -0,0 +1 @@
github: [cirruslabs]
+1 -1
View File
@@ -28,7 +28,7 @@ archives:
release:
prerelease: auto
extra_files:
- glob: ./dist/Tart.pkg
- glob: ./dist/Tart-{{ .Tag }}.pkg
brews:
- name: tart
+36 -2
View File
@@ -8,6 +8,22 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
* Use Tart Packer Plugin to automate VM creation.
* Built-in CI integration.
*Tart* is already adopted by several automation services:
<p align="center">
<a href="https://cirrus-ci.org/guide/macOS/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/CirrusCI.png" height="65"/>
</a>
<a href="https://codemagic.io/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Codemagic.png" height="65"/>
</a>
<a href="https://testingbot.com/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/TestingBot.png" height="65"/>
</a>
</p>
## Usage
Try running a Tart VM on your Apple Silicon device running macOS Monterey or later (will download a 25 GB image):
```shell
@@ -222,8 +238,8 @@ This invocation calls the `tart pull` implicitly (if the image is not being pres
<details>
<summary>Why Tart is free and open sourced?</summary>
Tart is a relatively small project, and it didn't feel right to try to monetize it.
Apple did all the heavy lifting with their `Virtualization.Framework`.
Apple did all the heavy lifting with their `Virtualization.Framework` and it just felt right to develop Tart in the open.
Please consider [becoming a sponsor](https://github.com/sponsors/cirruslabs) if you find Tart saving a substantial amount of money on licensing and engineering hours for your company.
</details>
<details>
@@ -252,3 +268,21 @@ This invocation calls the `tart pull` implicitly (if the image is not being pres
Tart is limited by functionality of Apple's `Virtualization.Framework`. At the moment `Virtualization.Framework`
doesn't support nested virtualization.
</details>
<details>
<summary>Changing the default NAT subnet</summary>
To change the default network to `192.168.77.1`:
```
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Address -string 192.168.77.1
```
Note that even through a network would normally be specified as `192.168.77.0`, the [vmnet framework](https://developer.apple.com/documentation/vmnet) seems to treat this as a starting address too and refuses to pick up such network-like values.
The default subnet mask `255.255.255.0` should suffice for most use-cases, however, you can also change it to `255.255.0.0`, for example:
```
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Mask -string 255.255.0.0
```
</details>
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dbcf6f65f26c0e17b2fe55a9712fc783315cd64905a0febce758b31d7e4e923b
size 6787
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4239996f30145992936cfc8faa0232fa8904aedf4de61e30ef4c4fc86cad587f
size 14588
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:227192fcd215c9cc05b1ebcd2616bd1f8c95be184726f64d91fc4507a88c66e1
size 18393
+3 -1
View File
@@ -4,5 +4,7 @@
<dict>
<key>com.apple.security.virtualization</key>
<true/>
<key>com.apple.vm.networking</key>
<true/>
</dict>
</plist>
</plist>
+11 -1
View File
@@ -32,12 +32,22 @@ struct Clone: AsyncParsableCommand {
}
let sourceVM = try VMStorageHelper.open(sourceName)
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
let tmpVMDir = try VMDirectory.temporary()
// Lock the temporary VM directory to prevent it's garbage collection
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
try tmpVMDirLock.lock()
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)
})
+5
View File
@@ -27,6 +27,11 @@ struct Create: AsyncParsableCommand {
func run() async throws {
do {
let tmpVMDir = try VMDirectory.temporary()
// Lock the temporary VM directory to prevent it's garbage collection
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
try tmpVMDirLock.lock()
try await withTaskCancellationHandler(operation: {
if let fromIPSW = fromIPSW {
if fromIPSW == "latest" {
+1 -1
View File
@@ -101,7 +101,7 @@ struct Prune: AsyncParsableCommand {
break
}
cacheReclaimedBytes -= try prunable.sizeBytes()
cacheReclaimedBytes += try prunable.sizeBytes()
try prunable.delete()
}
}
+40
View File
@@ -0,0 +1,40 @@
import ArgumentParser
import Foundation
struct Rename: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Rename a VM")
@Argument(help: "VM name")
var name: String
@Argument(help: "new VM name")
var newName: String
func validate() throws {
if newName.contains("/") {
throw ValidationError("<new-name> should be a local name")
}
}
func run() async throws {
do {
let localStorage = VMStorageLocal()
if !localStorage.exists(name) {
throw ValidationError("failed to rename a non-existent VM: \(name)")
}
if localStorage.exists(newName) {
throw ValidationError("failed to rename VM \(name), target VM \(name) already exists, delete it first!")
}
try localStorage.rename(name, newName)
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
}
}
}
+58 -3
View File
@@ -47,15 +47,28 @@ struct Run: AsyncParsableCommand {
@Option(help: ArgumentHelp("""
Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\")
""", discussion: """
Requires host to be macOS 13.0 (Ventura) or newer.
All shared directories are automatically mounted to "/Volumes/My Shared Files" directory on macOS,
while on Linux you have to do it manually: "mount -t virtiofs com.apple.virtio-fs.automount /mount/point".
For macOS guests, they must be running macOS 13.0 (Ventura) or newer.
""", valueName: "name:path[:ro]"))
var dir: [String] = []
@Option(help: ArgumentHelp("""
Use bridged networking instead of the default shared (NAT) networking \n(e.g. --net-bridged=en0 or --net-bridged=\"Wi-Fi\")
""", discussion: """
Specify "list" as an interface name (--net-bridged=list) to list the available bridged interfaces.
""", valueName: "interface name"))
var netBridged: String?
func validate() throws {
if vnc && vncExperimental {
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
}
if withSoftnet && netBridged != nil {
throw ValidationError("--with-softnet and --net-bridged are mutually exclusive")
}
}
@MainActor
@@ -63,7 +76,7 @@ struct Run: AsyncParsableCommand {
let vmDir = try VMStorageLocal().open(name)
vm = try VM(
vmDir: vmDir,
withSoftnet: withSoftnet,
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
additionalDiskAttachments: additionalDiskAttachments(),
directoryShares: directoryShares()
)
@@ -123,11 +136,53 @@ struct Run: AsyncParsableCommand {
}
}
func userSpecifiedNetwork(vmDir: VMDirectory) throws -> Network? {
if withSoftnet {
let config = try VMConfig.init(fromURL: vmDir.configURL)
return try Softnet(vmMACAddress: config.macAddress.string)
}
if let netBridged = netBridged {
let matchingInterfaces = VZBridgedNetworkInterface.networkInterfaces.filter { interface in
interface.identifier == netBridged || interface.localizedDisplayName == netBridged
}
if matchingInterfaces.isEmpty {
let available = bridgeInterfaces().joined(separator: ", ")
throw ValidationError("no bridge interfaces matched \"\(netBridged)\", "
+ "available interfaces: \(available)")
}
if matchingInterfaces.count > 1 {
throw ValidationError("more than one bridge interface matched \"\(netBridged)\", "
+ "consider refining the search criteria")
}
return NetworkBridged(interface: matchingInterfaces.first!)
}
return nil
}
func bridgeInterfaces() -> [String] {
VZBridgedNetworkInterface.networkInterfaces.map { interface in
var bridgeDescription = interface.identifier
if let localizedDisplayName = interface.localizedDisplayName {
bridgeDescription += " (or \"\(localizedDisplayName)\")"
}
return bridgeDescription
}
}
func additionalDiskAttachments() throws -> [VZDiskImageStorageDeviceAttachment] {
var result: [VZDiskImageStorageDeviceAttachment] = []
let readOnlySuffix = ":ro"
let expandedDiskPaths = disk.map { NSString(string:$0).expandingTildeInPath }
for rawDisk in disk {
for rawDisk in expandedDiskPaths {
if rawDisk.hasSuffix(readOnlySuffix) {
result.append(try VZDiskImageStorageDeviceAttachment(
url: URL(fileURLWithPath: String(rawDisk.prefix(rawDisk.count - readOnlySuffix.count))),
@@ -166,7 +221,7 @@ struct Run: AsyncParsableCommand {
let (name, path) = (String(splits[0]), String(splits[1]))
result.append(DirectoryShare(name: name, path: URL(fileURLWithPath: path), readOnly: readOnly))
result.append(DirectoryShare(name: name, path: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath), readOnly: readOnly))
}
return result
+21 -2
View File
@@ -3,8 +3,9 @@ import Foundation
struct Config {
let tartHomeDir: URL
let tartCacheDir: URL
let tartTmpDir: URL
init() {
init() throws {
var tartHomeDir: URL
if let customTartHome = ProcessInfo.processInfo.environment["TART_HOME"] {
@@ -14,9 +15,27 @@ struct Config {
.homeDirectoryForCurrentUser
.appendingPathComponent(".tart", isDirectory: true)
}
self.tartHomeDir = tartHomeDir
tartCacheDir = tartHomeDir.appendingPathComponent("cache", isDirectory: true)
try FileManager.default.createDirectory(at: tartCacheDir, withIntermediateDirectories: true)
tartTmpDir = tartHomeDir.appendingPathComponent("tmp", isDirectory: true)
try FileManager.default.createDirectory(at: tartTmpDir, withIntermediateDirectories: true)
}
func gc() throws {
for entry in try FileManager.default.contentsOfDirectory(at: tartTmpDir,
includingPropertiesForKeys: [], options: []) {
let lock = try FileLock(lockURL: entry)
if try !lock.trylock() {
continue
}
try FileManager.default.removeItem(at: entry)
try lock.unlock()
}
}
static func jsonEncoder() -> JSONEncoder {
@@ -1,6 +1,6 @@
import Foundation
class HelperProgramCredentialsProvider: CredentialsProvider {
class DockerConfigCredentialsProvider: CredentialsProvider {
func retrieve(host: String) throws -> (String, String)? {
let dockerConfigURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json")
if !FileManager.default.fileExists(atPath: dockerConfigURL.path) {
@@ -8,6 +8,9 @@ class HelperProgramCredentialsProvider: CredentialsProvider {
}
let config = try JSONDecoder().decode(DockerConfig.self, from: Data(contentsOf: dockerConfigURL))
if let credentialsFromAuth = config.auths?[host]?.decodeCredentials() {
return credentialsFromAuth
}
if let helperProgram = config.credHelpers?[host] {
return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host)
}
@@ -54,9 +57,31 @@ class HelperProgramCredentialsProvider: CredentialsProvider {
}
struct DockerConfig: Codable {
var auths: Dictionary<String, DockerAuthConfig>? = Dictionary()
var credHelpers: Dictionary<String, String>? = Dictionary()
}
struct DockerAuthConfig: Codable {
var auth: String? = nil
func decodeCredentials() -> (String, String)? {
// auth is a base64("username:password")
guard let authBase64 = auth else {
return nil
}
guard let data = Data(base64Encoded: authBase64) else {
return nil
}
guard let components = String(data: data, encoding: .utf8)?.components(separatedBy: ":") else {
return nil
}
if components.count != 2 {
return nil
}
return (components[0], components[1])
}
}
struct DockerGetOutput: Codable {
var Username: String
var Secret: String
+48
View File
@@ -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
}
}
+1 -1
View File
@@ -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)
}
+7
View File
@@ -0,0 +1,7 @@
import Virtualization
protocol Network {
func attachment() -> VZNetworkDeviceAttachment
func run() throws
func stop() throws
}
+22
View File
@@ -0,0 +1,22 @@
import Foundation
import Virtualization
class NetworkBridged: Network {
let interface: VZBridgedNetworkInterface
init(interface: VZBridgedNetworkInterface) {
self.interface = interface
}
func attachment() -> VZNetworkDeviceAttachment {
VZBridgedNetworkDeviceAttachment(interface: interface)
}
func run() throws {
// no-op, only used for Softnet
}
func stop() throws {
// no-op, only used for Softnet
}
}
+16
View File
@@ -0,0 +1,16 @@
import Foundation
import Virtualization
class NetworkShared: Network {
func attachment() -> VZNetworkDeviceAttachment {
VZNATNetworkDeviceAttachment()
}
func run() throws {
// no-op, only used for Softnet
}
func stop() throws {
// no-op, only used for Softnet
}
}
@@ -1,10 +1,11 @@
import Foundation
import Virtualization
enum SoftnetError: Error {
case InitializationFailed(why: String)
}
class Softnet {
class Softnet: Network {
private let process = Process()
let vmFD: Int32
@@ -57,4 +58,9 @@ class Softnet {
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_SNDBUF) returned \(ret)")
}
}
func attachment() -> VZNetworkDeviceAttachment {
let fh = FileHandle.init(fileDescriptor: vmFD)
return VZFileHandleNetworkDeviceAttachment(fileHandle: fh)
}
}
+2 -2
View File
@@ -94,7 +94,7 @@ class Registry {
init(urlComponents: URLComponents,
namespace: String,
credentialsProviders: [CredentialsProvider] = [HelperProgramCredentialsProvider(), KeychainCredentialsProvider()]
credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
) throws {
baseURL = urlComponents.url!
self.namespace = namespace
@@ -105,7 +105,7 @@ class Registry {
host: String,
namespace: String,
insecure: Bool = false,
credentialsProviders: [CredentialsProvider] = [HelperProgramCredentialsProvider(), KeychainCredentialsProvider()]
credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
) throws {
let proto = insecure ? "http" : "https"
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
+4
View File
@@ -17,6 +17,7 @@ struct Root: AsyncParsableCommand {
Pull.self,
Push.self,
Prune.self,
Rename.self,
Delete.self,
])
@@ -36,6 +37,9 @@ struct Root: AsyncParsableCommand {
do {
var command = try parseAsRoot()
// Run garbage-collection before each command (shouldn't take too long)
try Config().gc()
if var asyncCommand = command as? AsyncParsableCommand {
try await asyncCommand.run()
} else {
+12 -28
View File
@@ -14,7 +14,7 @@ struct UnsupportedOSError: Error, CustomStringConvertible {
let description: String
init(_ what: String, _ plural: String) {
description = "error: \(what) \(plural) only supported on macOS 13.0 (Ventura) or newer"
description = "error: \(what) \(plural) only supported on hosts running macOS 13.0 (Ventura) or newer"
}
}
@@ -34,10 +34,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// VM's config
var config: VMConfig
var softnet: Softnet? = nil
var network: Network
init(vmDir: VMDirectory,
withSoftnet: Bool = false,
network: Network = NetworkShared(),
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
directoryShares: [DirectoryShare] = []
) throws {
@@ -49,13 +49,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
}
// Initialize the virtual machine and its configuration
if withSoftnet {
softnet = try Softnet(vmMACAddress: config.macAddress.string)
}
self.network = network
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
nvramURL: vmDir.nvramURL, vmConfig: config,
softnet: softnet, additionalDiskAttachments: additionalDiskAttachments,
network: network, additionalDiskAttachments: additionalDiskAttachments,
directoryShares: directoryShares)
virtualMachine = VZVirtualMachine(configuration: configuration)
@@ -114,7 +111,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
vmDir: VMDirectory,
ipswURL: URL?,
diskSizeGB: UInt16,
withSoftnet: Bool = false,
network: Network = NetworkShared(),
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = []
) async throws {
let ipswURL = ipswURL != nil ? ipswURL! : try await VM.retrieveLatestIPSW();
@@ -149,12 +146,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
try config.save(toURL: vmDir.configURL)
// Initialize the virtual machine and its configuration
if withSoftnet {
softnet = try Softnet(vmMACAddress: config.macAddress.string)
}
self.network = network
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
vmConfig: config, softnet: softnet,
vmConfig: config, network: network,
additionalDiskAttachments: additionalDiskAttachments,
directoryShares: [])
virtualMachine = VZVirtualMachine(configuration: configuration)
@@ -193,9 +187,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
}
func run(_ recovery: Bool) async throws {
if let softnet = softnet {
try softnet.run()
}
try network.run()
DispatchQueue.main.sync {
Task {
@@ -225,16 +217,14 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
}
}
if let softnet = softnet {
try softnet.stop();
}
try network.stop()
}
static func craftConfiguration(
diskURL: URL,
nvramURL: URL,
vmConfig: VMConfig,
softnet: Softnet? = nil,
network: Network = NetworkShared(),
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
directoryShares: [DirectoryShare]
) throws -> VZVirtualMachineConfiguration {
@@ -268,13 +258,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Networking
let vio = VZVirtioNetworkDeviceConfiguration()
if let softnet = softnet {
let fh = FileHandle.init(fileDescriptor: softnet.vmFD)
vio.attachment = VZFileHandleNetworkDeviceAttachment(fileHandle: fh)
} else {
vio.attachment = VZNATNetworkDeviceAttachment()
}
vio.attachment = network.attachment()
vio.macAddress = vmConfig.macAddress
configuration.networkDevices = [vio]
+1 -1
View File
@@ -29,7 +29,7 @@ struct VMDirectory: Prunable {
}
static func temporary() throws -> VMDirectory {
let tmpDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let tmpDir = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: false)
return VMDirectory(baseURL: tmpDir)
+5 -1
View File
@@ -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)
@@ -32,6 +32,10 @@ class VMStorageLocal {
_ = try FileManager.default.replaceItemAt(vmURL(name), withItemAt: from.baseURL)
}
func rename(_ name: String, _ newName: String) throws {
_ = try FileManager.default.replaceItemAt(vmURL(newName), withItemAt: vmURL(name))
}
func delete(_ name: String) throws {
try FileManager.default.removeItem(at: vmURL(name))
}
+36 -4
View File
@@ -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,17 +129,41 @@ 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()
// Lock the temporary VM directory to prevent it's garbage collection
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
try tmpVMDirLock.lock()
// Try to reclaim some cache space if we know the VM size in advance
if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
let requiredCapacityBytes = UInt64(uncompressedDiskSize + 128 * 1024 * 1024)
let attrs = try tmpVMDir.baseURL.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
let availableCapacityBytes = UInt64(attrs.volumeAvailableCapacityForImportantUsage!)
let attrs = try Config().tartCacheDir.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey, .volumeAvailableCapacityKey])
let availableCapacityBytes = max(UInt64(attrs.volumeAvailableCapacityForImportantUsage!), UInt64(attrs.volumeAvailableCapacity!))
if availableCapacityBytes < requiredCapacityBytes {
// There is a suspicious that occasionally capacity is returned as zero which can't be true.
// Let's validate to avoid unnecessary pruning.
if 0 < availableCapacityBytes && availableCapacityBytes < requiredCapacityBytes {
try Prune.pruneReclaim(reclaimBytes: requiredCapacityBytes - availableCapacityBytes)
}
}
@@ -181,4 +209,8 @@ extension URL {
return result
}
func appendingHost(_ name: RemoteName) -> URL {
self.appendingPathComponent(name.host, isDirectory: true)
}
}
+34
View File
@@ -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
}
}
+1 -1
View File
@@ -7,6 +7,6 @@ apple_id {
}
sign {
application_identity = "Developer ID Application: Fedor Korotkov"
application_identity = "Developer ID Application: Cirrus Labs, Inc."
entitlements_file = "Resources/tart.entitlements"
}