Compare commits

...
17 Commits
Author SHA1 Message Date
Fedor Korotkov 9c879b3f55 tart run --nested to enable nested virtualization when available (#938)
Only works for Linux VMs under Sequoia hosts.

Fixes #933
Fixes #701
2024-11-06 21:27:29 +04:00
Nikolay Edigaryev f7b38769a9 tart pull: open the VM directory after pulling under a lock (#936) 2024-11-05 00:01:14 +01:00
Nikolay Edigaryev 7c1ed4640f Info.plist: do not use LSBackgroundOnly (#935) 2024-11-04 19:08:37 +00:00
Nikolay Edigaryev 3fb8069edd Linux VMs: do not use NVMe storage device (#932) 2024-10-31 16:35:12 -04:00
Nikolay Edigaryev c78c89e274 utimes(2): use errno to explain the error (#931) 2024-10-31 16:33:03 -04:00
Fedor Korotkov 770220f905 Fixed plist file in version update (#927) 2024-10-29 13:01:47 +04:00
Nikolay Edigaryev 768d1f9bad PROFILING.md: document how to profile Tart using time(1) and xctrace(1) (#926) 2024-10-28 18:49:10 +04:00
Fedor Korotkov d49ed46439 Update access time on pull (#925)
To make sure we won't prune then immediately after. Useful for when scenarios similar to Cirrus CLI when we make sure that several images are up-to-date before every request for task execution.
2024-10-25 23:33:30 +04:00
Nikolay Edigaryev b52a857698 tart {clone,pull}: make deduplication opt-in (#924) 2024-10-25 17:56:38 +04:00
Nikolay Edigaryev 3bf0bb22f3 CI: populate CFBundleShortVersionString in Info.plist (#923) 2024-10-24 18:47:50 +00:00
Nikolay Edigaryev accbd0cb33 Registry: prevent double authorization when getting a new token (#922) 2024-10-23 23:51:55 +04:00
Nikolay Edigaryev c0b20932c7 Prevent pipe deadlock when spawning a Process() (#916) 2024-10-02 09:48:23 -04:00
Nikolay Edigaryev 3694af946c Document automatic pruning in FAQ (#913)
* Document automatic pruning in FAQ

* tart {pull,clone}: consistent automatic pruning documentation in --help
2024-09-30 21:56:55 +04:00
Nikolay Edigaryev dbf711a6c9 tart delete: return human-friendly error when local VM doesn't exist (#910) 2024-09-26 14:45:14 +04:00
Nikolay Edigaryev b9f24a40c1 Info.plist: set CFBundleName and CFBundleDisplayName to Tart (#909) 2024-09-24 17:24:50 +04:00
Nikolay Edigaryev 10c6ace671 Re-generate ANTLR files using ANTLR 4.13.2 (#907)
* Re-generate ANTLR files using ANTLR 4.13.2

* Package.swift: require exactly ANTLR of version 4.13.2
2024-09-20 17:50:14 +04:00
Nikolay Edigaryev b98e23956b Package.swift: bump Sentry SDK to 8.36.0 + upgrade other packages (#905)
* Package.swift: bump Sentry SDK to 8.36.0

* $ swift package update
2024-09-19 19:06:37 +00:00
29 changed files with 233 additions and 99 deletions
+2
View File
@@ -3,3 +3,5 @@
TMPFILE=$(mktemp) TMPFILE=$(mktemp)
envsubst < Sources/tart/CI/CI.swift > $TMPFILE envsubst < Sources/tart/CI/CI.swift > $TMPFILE
mv $TMPFILE Sources/tart/CI/CI.swift mv $TMPFILE Sources/tart/CI/CI.swift
/usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string ${CIRRUS_TAG}" Resources/Info.plist
+64
View File
@@ -0,0 +1,64 @@
# Profiling Tart
## Using `time(1)`
Perhaps, the easiest, but not the most comprehensive way to tell what's going on with Tart is to use the [`time(1)`](https://ss64.com/mac/time.html) command.
In the example below, you will run `tart pull` via `time(1)` to gather generalized CPU, I/O and memory usage metrics:
```shell
/usr/bin/time -l tart pull ghcr.io/cirruslabs/macos-sequoia-base:latest
```
**Note:** you need to specify a full path to `time(1)` binary, otherwise the shell's built-in `time` command will be invoked, which doesn't have the `-l` command-line argument.
**Note:** The `-l` command-line argument makes `time(1)` return much more useful information, for example, maximum memory usage.
When running the command above, you'll see the `tart pull` output first as it pulls the image, and then the `time(1)` output, which will be printed once the Tart process finishes:
```
172.17 real 10.29 user 8.36 sys
353796096 maximum resident set size
0 average shared memory size
0 average unshared data size
0 average unshared stack size
23838 page reclaims
35 page faults
0 swaps
0 block input operations
0 block output operations
8 messages sent
8 messages received
0 signals received
146 voluntary context switches
222950 involuntary context switches
39683070975 instructions retired
27562035252 cycles elapsed
170920448 peak memory footprint
```
From the output above, you can tell that `tart pull` spent nearly 90% of time off-CPU (`real` > `user` + `sys`), which means that Tart was mostly waiting for the I/O (be it a network or disk), instead of decompressing disk layers or doing other useful computations.
## Using `xctrace(1)`
[`xctrace(1)`](https://keith.github.io/xcode-man-pages/xctrace.1.html) is a `.trace` format recorder for the [Instruments](https://en.wikipedia.org/wiki/Instruments_(software)) app, which yields much more powerful insights compared to `time(1)`. For example, it can tell which Tart functions spent the most time on the CPU, thus allowing the Tart developers to further optimize these functions.
To use it, make sure that [Xcode](https://developer.apple.com/xcode/resources/) is installed. If you're installing Xcode for the first time on the machine, you'll need to launch it once and click the blue "Install" button. There's no need to choose any platforms except for the macOS.
Once done, you can create a CPU profile of `tart pull`:
```shell
xctrace record --template "CPU Profiler" --target-stdout - --launch -- /opt/homebrew/bin/tart pull ghcr.io/cirruslabs/macos-sequoia-base:latest
```
Now that `xctrace(1)` is running, you'll see the `tart pull`-related output first, and once finished, the following line will appear:
```
Output file saved as: Launch_[...].trace
```
To view this trace in the Instruments app, simply find this directory in Finder and double-click it. Instruments app will appear:
![](Resources/Instruments.png)
To send this trace, right-click its directory in Finder and choose "Compress [...]". This will result in a similarly named file with a `.zip` at the end, which can now be conveniently sent via email or uploaded.
+18 -18
View File
@@ -1,13 +1,13 @@
{ {
"originHash" : "2c514a4a1d7e106713db744bee89edb40d75da63e6611990ec2f4b0da53c0455", "originHash" : "6a15657d8cb1d3e2b447f31aff5b47d6a9655d2262e48ca76476ba525435269b",
"pins" : [ "pins" : [
{ {
"identity" : "antlr4", "identity" : "antlr4",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/antlr/antlr4", "location" : "https://github.com/antlr/antlr4",
"state" : { "state" : {
"branch" : "dev", "revision" : "cc82115a4e7f53d71d9d905caa2c2dfa4da58899",
"revision" : "2703a8516c0fb7fe92db6b9c40e0113f577646d2" "version" : "4.13.2"
} }
}, },
{ {
@@ -24,8 +24,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/groue/Semaphore", "location" : "https://github.com/groue/Semaphore",
"state" : { "state" : {
"revision" : "f1c4a0acabeb591068dea6cffdd39660b86dec28", "revision" : "2543679282aa6f6c8ecf2138acd613ed20790bc2",
"version" : "0.0.8" "version" : "0.1.0"
} }
}, },
{ {
@@ -33,8 +33,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/getsentry/sentry-cocoa", "location" : "https://github.com/getsentry/sentry-cocoa",
"state" : { "state" : {
"revision" : "ef4fec9dfb8dd5027b09a4a5c9362feafd118e1a", "revision" : "5575af93efb776414f243e93d6af9f6258dc539a",
"version" : "8.24.0" "version" : "8.36.0"
} }
}, },
{ {
@@ -51,8 +51,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser", "location" : "https://github.com/apple/swift-argument-parser",
"state" : { "state" : {
"revision" : "46989693916f56d1186bd59ac15124caef896560", "revision" : "41982a3656a71c768319979febd796c6fd111d5c",
"version" : "1.3.1" "version" : "1.5.0"
} }
}, },
{ {
@@ -61,7 +61,7 @@
"location" : "https://github.com/apple/swift-async-algorithms", "location" : "https://github.com/apple/swift-async-algorithms",
"state" : { "state" : {
"branch" : "main", "branch" : "main",
"revision" : "f05e450f0b909c0e80670a47516c4b9700b9e5da" "revision" : "5c8bd186f48c16af0775972700626f0b74588278"
} }
}, },
{ {
@@ -78,8 +78,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git", "location" : "https://github.com/apple/swift-collections.git",
"state" : { "state" : {
"revision" : "f504716c27d2e5d4144fa4794b12129301d17729", "revision" : "9bf03ff58ce34478e66aaee630e491823326fd06",
"version" : "1.0.3" "version" : "1.1.3"
} }
}, },
{ {
@@ -87,8 +87,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git", "location" : "https://github.com/apple/swift-log.git",
"state" : { "state" : {
"revision" : "e97a6fcb1ab07462881ac165fdbb37f067e205d5", "revision" : "9cb486020ebf03bfa5b5df985387a14a98744537",
"version" : "1.5.4" "version" : "1.6.1"
} }
}, },
{ {
@@ -105,8 +105,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/fumoboy007/swift-retry", "location" : "https://github.com/fumoboy007/swift-retry",
"state" : { "state" : {
"revision" : "9f133487ffc2ab4539688c29efe57bb1ba31d7b0", "revision" : "df9d7b185d2e433147ec0083a73c257e665eea0d",
"version" : "0.2.3" "version" : "0.2.4"
} }
}, },
{ {
@@ -141,8 +141,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/nicklockwood/SwiftFormat", "location" : "https://github.com/nicklockwood/SwiftFormat",
"state" : { "state" : {
"revision" : "9df3b01f477163b33d5e63c5e2e5b9f946a49c56", "revision" : "ab6844edb79a7b88dc6320e6cee0a0db7674dac3",
"version" : "0.53.6" "version" : "0.54.5"
} }
}, },
{ {
+2 -2
View File
@@ -15,10 +15,10 @@ let package = Package(
.package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0"), .package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0"),
.package(url: "https://github.com/apple/swift-async-algorithms", branch: "main"), .package(url: "https://github.com/apple/swift-async-algorithms", branch: "main"),
.package(url: "https://github.com/malcommac/SwiftDate", from: "7.0.0"), .package(url: "https://github.com/malcommac/SwiftDate", from: "7.0.0"),
.package(url: "https://github.com/antlr/antlr4", branch: "dev"), .package(url: "https://github.com/antlr/antlr4", exact: "4.13.2"),
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.2.0")), .package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.2.0")),
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.53.6"), .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.53.6"),
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.24.0"), .package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.36.0"),
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"), .package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
.package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.8.0"), .package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.8.0"),
.package(url: "https://github.com/orchetect/SwiftRadix", from: "1.3.1"), .package(url: "https://github.com/orchetect/SwiftRadix", from: "1.3.1"),
+17 -17
View File
@@ -2,22 +2,22 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>tart</string> <string>Tart</string>
<key>CFBundleIdentifier</key> <key>CFBundleDisplayName</key>
<string>org.cirruslabs.tart</string> <string>Tart</string>
<key>CFBundleExecutable</key> <key>CFBundleIdentifier</key>
<string>tart</string> <string>org.cirruslabs.tart</string>
<key>LSBackgroundOnly</key> <key>CFBundleExecutable</key>
<string>1</string> <string>tart</string>
<key>CFBundleIconFiles</key> <key>CFBundleIconFiles</key>
<array> <array>
<string>AppIcon.png</string> <string>AppIcon.png</string>
</array> </array>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoads</key> <key>NSAllowsArbitraryLoads</key>
<true/> <true/>
</dict> </dict>
</dict> </dict>
</plist> </plist>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+7 -6
View File
@@ -9,12 +9,10 @@ struct Clone: AsyncParsableCommand {
Creates a local virtual machine by cloning either a remote or another local virtual machine. Creates a local virtual machine by cloning either a remote or another local virtual machine.
Due to copy-on-write magic in Apple File System, a cloned VM won't actually claim all the space right away. Due to copy-on-write magic in Apple File System, a cloned VM won't actually claim all the space right away.
Only changes to a cloned disk will be written and claim new space. By default, Tart checks available capacity Only changes to a cloned disk will be written and claim new space. This also speeds up clones enormously.
in Tart's home directory and checks if there is enough space for the worst possible scenario: when the whole disk
will be modified.
This behaviour can be disabled by setting TART_NO_AUTO_PRUNE environment variable. This might be helpful By default, Tart checks available capacity in Tart's home directory and tries to reclaim minimum possible storage for the cloned image
for use cases when the original image is very big and a workload is known to only modify a fraction of the cloned disk. to fit. This behaviour is called "automatic pruning" and can be disabled by setting TART_NO_AUTO_PRUNE environment variable.
""" """
) )
@@ -30,6 +28,9 @@ struct Clone: AsyncParsableCommand {
@Option(help: "network concurrency to use when pulling a remote VM from the OCI-compatible registry") @Option(help: "network concurrency to use when pulling a remote VM from the OCI-compatible registry")
var concurrency: UInt = 4 var concurrency: UInt = 4
@Flag(help: .hidden)
var deduplicate: Bool = false
func validate() throws { func validate() throws {
if newName.contains("/") { if newName.contains("/") {
throw ValidationError("<new-name> should be a local name") throw ValidationError("<new-name> should be a local name")
@@ -47,7 +48,7 @@ struct Clone: AsyncParsableCommand {
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) { if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
// Pull the VM in case it's OCI-based and doesn't exist locally yet // Pull the VM in case it's OCI-based and doesn't exist locally yet
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure) let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency) try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate)
} }
let sourceVM = try VMStorageHelper.open(sourceName) let sourceVM = try VMStorageHelper.open(sourceName)
+6 -3
View File
@@ -9,8 +9,8 @@ struct Pull: AsyncParsableCommand {
Pulls a virtual machine from a remote OCI-compatible registry. Supports authorization via Keychain (see "tart login --help"), Pulls a virtual machine from a remote OCI-compatible registry. Supports authorization via Keychain (see "tart login --help"),
Docker credential helpers defined in ~/.docker/config.json or via TART_REGISTRY_USERNAME/TART_REGISTRY_PASSWORD environment variables. Docker credential helpers defined in ~/.docker/config.json or via TART_REGISTRY_USERNAME/TART_REGISTRY_PASSWORD environment variables.
By default, Tart checks available capacity in Tart's home directory and tries to reclaim minimum possible storage for the remote image to fit via "tart prune". By default, Tart checks available capacity in Tart's home directory and tries to reclaim minimum possible storage for the remote image
This behaviour can be disabled by setting TART_NO_AUTO_PRUNE environment variable. to fit. This behaviour is called "automatic pruning" and can be disabled by setting TART_NO_AUTO_PRUNE environment variable.
""" """
) )
@@ -23,6 +23,9 @@ struct Pull: AsyncParsableCommand {
@Option(help: "network concurrency to use when pulling a remote VM from the OCI-compatible registry") @Option(help: "network concurrency to use when pulling a remote VM from the OCI-compatible registry")
var concurrency: UInt = 4 var concurrency: UInt = 4
@Flag(help: .hidden)
var deduplicate: Bool = false
func validate() throws { func validate() throws {
if concurrency < 1 { if concurrency < 1 {
throw ValidationError("network concurrency cannot be less than 1") throw ValidationError("network concurrency cannot be less than 1")
@@ -43,6 +46,6 @@ struct Pull: AsyncParsableCommand {
defaultLogger.appendNewLine("pulling \(remoteName)...") defaultLogger.appendNewLine("pulling \(remoteName)...")
try await VMStorageOCI().pull(remoteName, registry: registry, concurrency: concurrency) try await VMStorageOCI().pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate)
} }
} }
+12
View File
@@ -156,6 +156,9 @@ struct Run: AsyncParsableCommand {
""", valueName: "[name:]path[:options]")) """, valueName: "[name:]path[:options]"))
var dir: [String] = [] var dir: [String] = []
@Flag(help: ArgumentHelp("Enable nested virtualization if possible"))
var nested: Bool = false
@Option(help: ArgumentHelp(""" @Option(help: ArgumentHelp("""
Use bridged networking instead of the default shared (NAT) networking \n(e.g. --net-bridged=en0 or --net-bridged=\"Wi-Fi\") Use bridged networking instead of the default shared (NAT) networking \n(e.g. --net-bridged=en0 or --net-bridged=\"Wi-Fi\")
""", discussion: """ """, discussion: """
@@ -221,6 +224,14 @@ struct Run: AsyncParsableCommand {
throw ValidationError("--captures-system-keys can only be used with the default VM view") throw ValidationError("--captures-system-keys can only be used with the default VM view")
} }
if nested {
if #unavailable(macOS 15) {
throw ValidationError("Nested virtualization is supported on hosts starting with macOS 15 (Sequia), and later.")
} else if !VZGenericPlatformConfiguration.isNestedVirtualizationSupported {
throw ValidationError("Nested virtualization is available for Mac with the M3 chip, and later.")
}
}
let localStorage = VMStorageLocal() let localStorage = VMStorageLocal()
let vmDir = try localStorage.open(name) let vmDir = try localStorage.open(name)
if try vmDir.state() == .Suspended { if try vmDir.state() == .Suspended {
@@ -294,6 +305,7 @@ struct Run: AsyncParsableCommand {
directorySharingDevices: directoryShares() + rosettaDirectoryShare(), directorySharingDevices: directoryShares() + rosettaDirectoryShare(),
serialPorts: serialPorts, serialPorts: serialPorts,
suspendable: suspendable, suspendable: suspendable,
nested: nested,
audio: !noAudio, audio: !noAudio,
clipboard: !noClipboard, clipboard: !noClipboard,
sync: VZDiskImageSynchronizationMode(diskOptions.syncModeRaw) sync: VZDiskImageSynchronizationMode(diskOptions.syncModeRaw)
@@ -39,9 +39,10 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!) inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!)
inPipe.fileHandleForWriting.closeFile() inPipe.fileHandleForWriting.closeFile()
let outputData = try outPipe.fileHandleForReading.readToEnd()
process.waitUntilExit() process.waitUntilExit()
let outputData = try outPipe.fileHandleForReading.readToEnd()
if !(process.terminationReason == .exit && process.terminationStatus == 0) { if !(process.terminationReason == .exit && process.terminationStatus == 0) {
if let outputData = outputData { if let outputData = outputData {
print(String(decoding: outputData, as: UTF8.self)) print(String(decoding: outputData, as: UTF8.self))
@@ -52,6 +52,11 @@ struct ARPCache {
process.standardInput = FileHandle.nullDevice process.standardInput = FileHandle.nullDevice
try process.run() try process.run()
guard let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() else {
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
}
process.waitUntilExit() process.waitUntilExit()
if !(process.terminationReason == .exit && process.terminationStatus == 0) { if !(process.terminationReason == .exit && process.terminationStatus == 0) {
@@ -60,10 +65,6 @@ struct ARPCache {
terminationStatus: process.terminationStatus) terminationStatus: process.terminationStatus)
} }
guard let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() else {
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
}
self.arpCommandOutput = arpCommandOutput self.arpCommandOutput = arpCommandOutput
} }
+1 -1
View File
@@ -2,5 +2,5 @@ import Foundation
protocol Disk { protocol Disk {
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer] static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer]
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache?) async throws static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache?, deduplicate: Bool) async throws
} }
+1 -1
View File
@@ -45,7 +45,7 @@ class DiskV1: Disk {
return pushedLayers return pushedLayers
} }
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil) async throws { static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil, deduplicate: Bool = false) async throws {
if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) { if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
throw OCIError.FailedToCreateVmFile throw OCIError.FailedToCreateVmFile
} }
+20 -11
View File
@@ -69,12 +69,12 @@ class DiskV2: Disk {
} }
} }
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil) async throws { static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil, deduplicate: Bool = false) async throws {
// Support resumable pulls // Support resumable pulls
let pullResumed = FileManager.default.fileExists(atPath: diskURL.path) let pullResumed = FileManager.default.fileExists(atPath: diskURL.path)
if !pullResumed { if !pullResumed {
if let localLayerCache = localLayerCache { if deduplicate, let localLayerCache = localLayerCache {
// Clone the local layer cache's disk and use it as a base, potentially // Clone the local layer cache's disk and use it as a base, potentially
// reducing the space usage since some blocks won't be written at all // reducing the space usage since some blocks won't be written at all
try FileManager.default.copyItem(at: localLayerCache.diskURL, to: diskURL) try FileManager.default.copyItem(at: localLayerCache.diskURL, to: diskURL)
@@ -151,26 +151,31 @@ class DiskV2: Disk {
// Also open the disk file for reading and verifying // Also open the disk file for reading and verifying
// its contents in case the local layer cache is used // its contents in case the local layer cache is used
let rdisk: FileHandle? = if localLayerCache != nil { let rdisk: FileHandle? = if deduplicate && localLayerCache != nil {
try FileHandle(forReadingFrom: diskURL) try FileHandle(forReadingFrom: diskURL)
} else { } else {
nil nil
} }
// Check if we already have this layer contents in the local layer cache // Check if we already have this layer contents in the local layer cache,
if let localLayerCache = localLayerCache, let localLayerInfo = localLayerCache.findInfo(digest: diskLayer.digest, offsetHint: diskWritingOffset) { // or perhaps even on the cloned disk (when the deduplication is enabled)
// indicates that the locally cloned disk image has the same content at the given offset if let localLayerCache = localLayerCache,
let localHit = localLayerInfo.uncompressedContentDigest == uncompressedLayerContentDigest let localLayerInfo = localLayerCache.findInfo(digest: diskLayer.digest, offsetHint: diskWritingOffset),
&& localLayerInfo.range.lowerBound == diskWritingOffset localLayerInfo.uncompressedContentDigest == uncompressedLayerContentDigest {
// doesn't seem that localHit can ever be false if the localLayerCache is not nil if deduplicate && localLayerInfo.range.lowerBound == diskWritingOffset {
// but let's just add extra safety here and check it // Do nothing, because the data is already on the disk that we've inherited from
if !localHit { } else {
// Fulfil the layer contents from the local blob cache // Fulfil the layer contents from the local blob cache
let data = localLayerCache.subdata(localLayerInfo.range) let data = localLayerCache.subdata(localLayerInfo.range)
_ = try zeroSkippingWrite(disk, rdisk, fsBlockSize, diskWritingOffset, data) _ = try zeroSkippingWrite(disk, rdisk, fsBlockSize, diskWritingOffset, data)
} }
try disk.close() try disk.close()
if let rdisk = rdisk {
try rdisk.close()
}
// Update the progress // Update the progress
progress.completedUnitCount += Int64(diskLayer.size) progress.completedUnitCount += Int64(diskLayer.size)
@@ -198,6 +203,10 @@ class DiskV2: Disk {
try filter.finalize() try filter.finalize()
try disk.close() try disk.close()
if let rdisk = rdisk {
try rdisk.close()
}
} }
globalDiskWritingOffset += uncompressedLayerSize globalDiskWritingOffset += uncompressedLayerSize
@@ -1,4 +1,4 @@
// Generated from java-escape by ANTLR 4.11.1 // Generated from Reference.g4 by ANTLR 4.13.2
import Antlr4 import Antlr4
@@ -1,4 +1,4 @@
// Generated from java-escape by ANTLR 4.11.1 // Generated from Reference.g4 by ANTLR 4.13.2
import Antlr4 import Antlr4
open class ReferenceLexer: Lexer { open class ReferenceLexer: Lexer {
@@ -49,7 +49,7 @@ open class ReferenceLexer: Lexer {
public public
required init(_ input: CharStream) { required init(_ input: CharStream) {
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION) RuntimeMetaData.checkVersion("4.13.2", RuntimeMetaData.VERSION)
super.init(input) super.init(input)
_interp = LexerATNSimulator(self, ReferenceLexer._ATN, ReferenceLexer._decisionToDFA, ReferenceLexer._sharedContextCache) _interp = LexerATNSimulator(self, ReferenceLexer._ATN, ReferenceLexer._decisionToDFA, ReferenceLexer._sharedContextCache)
} }
@@ -1,4 +1,4 @@
// Generated from java-escape by ANTLR 4.11.1 // Generated from Reference.g4 by ANTLR 4.13.2
import Antlr4 import Antlr4
/** /**
@@ -1,4 +1,4 @@
// Generated from java-escape by ANTLR 4.11.1 // Generated from Reference.g4 by ANTLR 4.13.2
import Antlr4 import Antlr4
open class ReferenceParser: Parser { open class ReferenceParser: Parser {
@@ -41,7 +41,7 @@ open class ReferenceParser: Parser {
static let VOCABULARY = Vocabulary(_LITERAL_NAMES, _SYMBOLIC_NAMES) static let VOCABULARY = Vocabulary(_LITERAL_NAMES, _SYMBOLIC_NAMES)
override open override open
func getGrammarFileName() -> String { return "java-escape" } func getGrammarFileName() -> String { return "Reference.g4" }
override open override open
func getRuleNames() -> [String] { return ReferenceParser.ruleNames } func getRuleNames() -> [String] { return ReferenceParser.ruleNames }
@@ -60,7 +60,7 @@ open class ReferenceParser: Parser {
override public override public
init(_ input:TokenStream) throws { init(_ input:TokenStream) throws {
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION) RuntimeMetaData.checkVersion("4.13.2", RuntimeMetaData.VERSION)
try super.init(input) try super.init(input)
_interp = ParserATNSimulator(self,ReferenceParser._ATN,ReferenceParser._decisionToDFA, ReferenceParser._sharedContextCache) _interp = ParserATNSimulator(self,ReferenceParser._ATN,ReferenceParser._decisionToDFA, ReferenceParser._sharedContextCache)
} }
@@ -460,7 +460,7 @@ open class ReferenceParser: Parser {
setState(63) setState(63)
try _errHandler.sync(self) try _errHandler.sync(self)
_la = try _input.LA(1) _la = try _input.LA(1)
if ((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0) { if (((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0)) {
setState(62) setState(62)
try separator() try separator()
@@ -611,7 +611,7 @@ open class ReferenceParser: Parser {
setState(84) setState(84)
try _errHandler.sync(self) try _errHandler.sync(self)
_la = try _input.LA(1) _la = try _input.LA(1)
while ((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0) { while (((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0)) {
setState(79) setState(79)
try separator() try separator()
setState(80) setState(80)
@@ -664,7 +664,7 @@ open class ReferenceParser: Parser {
try enterOuterAlt(_localctx, 1) try enterOuterAlt(_localctx, 1)
setState(87) setState(87)
_la = try _input.LA(1) _la = try _input.LA(1)
if (!((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0)) { if (!(((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0))) {
try _errHandler.recoverInline(self) try _errHandler.recoverInline(self)
} }
else { else {
+7 -5
View File
@@ -327,12 +327,12 @@ class Registry {
request.httpBody = body request.httpBody = body
} }
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile) var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile, doAuth: doAuth)
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue { if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
_ = try await channel.asData() _ = try await channel.asData()
try await auth(response: response) try await auth(response: response)
(channel, response) = try await authAwareRequest(request: request, viaFile: viaFile) (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile, doAuth: doAuth)
} }
return (channel, response) return (channel, response)
@@ -413,11 +413,13 @@ class Registry {
return nil return nil
} }
private func authAwareRequest(request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) { private func authAwareRequest(request: URLRequest, viaFile: Bool = false, doAuth: Bool) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
var request = request var request = request
if let (name, value) = await authenticationKeeper.header() { if doAuth {
request.addValue(value, forHTTPHeaderField: name) if let (name, value) = await authenticationKeeper.header() {
request.addValue(value, forHTTPHeaderField: name)
}
} }
request.setValue("Tart/\(CI.version) (\(DeviceInfo.os); \(DeviceInfo.model))", request.setValue("Tart/\(CI.version) (\(DeviceInfo.os); \(DeviceInfo.model))",
+1 -1
View File
@@ -11,7 +11,7 @@ class PIDLock {
if fd == -1 { if fd == -1 {
let details = Errno(rawValue: CInt(errno)) let details = Errno(rawValue: CInt(errno))
throw RuntimeError.PIDLockFailed("failed to open lock file \(url): \(details)") throw RuntimeError.PIDLockMissing("failed to open lock file \(url): \(details)")
} }
} }
+5 -1
View File
@@ -58,7 +58,11 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
VZMacOSBootLoader() VZMacOSBootLoader()
} }
func platform(nvramURL: URL) throws -> VZPlatformConfiguration { func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration {
if needsNestedVirtualization {
throw RuntimeError.VMConfigurationError("macOS virtual machines do not support nested virtualization")
}
let result = VZMacPlatformConfiguration() let result = VZMacPlatformConfiguration()
result.machineIdentifier = ecid result.machineIdentifier = ecid
+6 -2
View File
@@ -14,8 +14,12 @@ struct Linux: Platform {
return result return result
} }
func platform(nvramURL: URL) throws -> VZPlatformConfiguration { func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration {
VZGenericPlatformConfiguration() let config = VZGenericPlatformConfiguration()
if #available(macOS 15, *) {
config.isNestedVirtualizationEnabled = needsNestedVirtualization
}
return config
} }
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration { func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
+1 -1
View File
@@ -3,7 +3,7 @@ import Virtualization
protocol Platform: Codable { protocol Platform: Codable {
func os() -> OS func os() -> OS
func bootLoader(nvramURL: URL) throws -> VZBootLoader func bootLoader(nvramURL: URL) throws -> VZBootLoader
func platform(nvramURL: URL) throws -> VZPlatformConfiguration func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
func keyboards() -> [VZKeyboardConfiguration] func keyboards() -> [VZKeyboardConfiguration]
func pointingDevices() -> [VZPointingDeviceConfiguration] func pointingDevices() -> [VZPointingDeviceConfiguration]
+4 -1
View File
@@ -1,4 +1,5 @@
import Foundation import Foundation
import System
extension URL { extension URL {
func accessDate() throws -> Date { func accessDate() throws -> Date {
@@ -13,7 +14,9 @@ extension URL {
let times = [accessDate.asTimeval(), modificationDate.asTimeval()] let times = [accessDate.asTimeval(), modificationDate.asTimeval()]
let ret = utimes(path, times) let ret = utimes(path, times)
if ret != 0 { if ret != 0 {
throw RuntimeError.FailedToUpdateAccessDate("utimes(2) failed: \(ret.explanation())") let details = Errno(rawValue: CInt(errno))
throw RuntimeError.FailedToUpdateAccessDate("utimes(2) failed: \(details)")
} }
} }
} }
+5 -8
View File
@@ -47,6 +47,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [], directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
serialPorts: [VZSerialPortConfiguration] = [], serialPorts: [VZSerialPortConfiguration] = [],
suspendable: Bool = false, suspendable: Bool = false,
nested: Bool = false,
audio: Bool = true, audio: Bool = true,
clipboard: Bool = true, clipboard: Bool = true,
sync: VZDiskImageSynchronizationMode = .full sync: VZDiskImageSynchronizationMode = .full
@@ -66,6 +67,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
directorySharingDevices: directorySharingDevices, directorySharingDevices: directorySharingDevices,
serialPorts: serialPorts, serialPorts: serialPorts,
suspendable: suspendable, suspendable: suspendable,
nested: nested,
audio: audio, audio: audio,
clipboard: clipboard, clipboard: clipboard,
sync: sync sync: sync
@@ -295,6 +297,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
directorySharingDevices: [VZDirectorySharingDeviceConfiguration], directorySharingDevices: [VZDirectorySharingDeviceConfiguration],
serialPorts: [VZSerialPortConfiguration], serialPorts: [VZSerialPortConfiguration],
suspendable: Bool = false, suspendable: Bool = false,
nested: Bool = false,
audio: Bool = true, audio: Bool = true,
clipboard: Bool = true, clipboard: Bool = true,
sync: VZDiskImageSynchronizationMode = .full sync: VZDiskImageSynchronizationMode = .full
@@ -309,7 +312,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
configuration.memorySize = vmConfig.memorySize configuration.memorySize = vmConfig.memorySize
// Platform // Platform
configuration.platform = try vmConfig.platform.platform(nvramURL: nvramURL) configuration.platform = try vmConfig.platform.platform(nvramURL: nvramURL, needsNestedVirtualization: nested)
// Display // Display
configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)] configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
@@ -365,13 +368,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false, cachingMode: .cached, synchronizationMode: sync) : try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false, cachingMode: .cached, synchronizationMode: sync) :
try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false, cachingMode: .automatic, synchronizationMode: sync) try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false, cachingMode: .automatic, synchronizationMode: sync)
var device: VZStorageDeviceConfiguration var devices: [VZStorageDeviceConfiguration] = [VZVirtioBlockDeviceConfiguration(attachment: attachment)]
if #available(macOS 14, *), vmConfig.os == .linux {
device = VZNVMExpressControllerDeviceConfiguration(attachment: attachment)
} else {
device = VZVirtioBlockDeviceConfiguration(attachment: attachment)
}
var devices: [VZStorageDeviceConfiguration] = [device]
devices.append(contentsOf: additionalStorageDevices) devices.append(contentsOf: additionalStorageDevices)
configuration.storageDevices = devices configuration.storageDevices = devices
+4 -3
View File
@@ -11,7 +11,7 @@ enum OCIError: Error {
} }
extension VMDirectory { extension VMDirectory {
func pullFromRegistry(registry: Registry, manifest: OCIManifest, concurrency: UInt, localLayerCache: LocalLayerCache?) async throws { func pullFromRegistry(registry: Registry, manifest: OCIManifest, concurrency: UInt, localLayerCache: LocalLayerCache?, deduplicate: Bool) async throws {
// Pull VM's config file layer and re-serialize it into a config file // Pull VM's config file layer and re-serialize it into a config file
let configLayers = manifest.layers.filter { let configLayers = manifest.layers.filter {
$0.mediaType == configMediaType $0.mediaType == configMediaType
@@ -54,12 +54,13 @@ extension VMDirectory {
do { do {
try await diskImplType.pull(registry: registry, diskLayers: layers, diskURL: diskURL, try await diskImplType.pull(registry: registry, diskLayers: layers, diskURL: diskURL,
concurrency: concurrency, progress: progress, concurrency: concurrency, progress: progress,
localLayerCache: localLayerCache) localLayerCache: localLayerCache,
deduplicate: deduplicate)
} catch let error where error is FilterError { } catch let error where error is FilterError {
throw RuntimeError.PullFailed("failed to decompress disk: \(error.localizedDescription)") throw RuntimeError.PullFailed("failed to decompress disk: \(error.localizedDescription)")
} }
if let llc = localLayerCache { if deduplicate, let llc = localLayerCache {
// set custom attribute to remember deduplicated bytes // set custom attribute to remember deduplicated bytes
diskURL.setDeduplicatedBytes(llc.deduplicatedBytes) diskURL.setDeduplicatedBytes(llc.deduplicatedBytes)
} }
+5
View File
@@ -24,6 +24,8 @@ class VMStorageHelper {
private static func missingVMWrap<R: Any>(_ name: String, closure: () throws -> R) throws -> R { private static func missingVMWrap<R: Any>(_ name: String, closure: () throws -> R) throws -> R {
do { do {
return try closure() return try closure()
} catch RuntimeError.PIDLockMissing {
throw RuntimeError.VMDoesNotExist(name: name)
} catch { } catch {
if error.isFileNotFound() { if error.isFileNotFound() {
throw RuntimeError.VMDoesNotExist(name: name) throw RuntimeError.VMDoesNotExist(name: name)
@@ -59,6 +61,7 @@ enum RuntimeError : Error {
case InvalidDiskSize(_ message: String) case InvalidDiskSize(_ message: String)
case FailedToUpdateAccessDate(_ message: String) case FailedToUpdateAccessDate(_ message: String)
case PIDLockFailed(_ message: String) case PIDLockFailed(_ message: String)
case PIDLockMissing(_ message: String)
case FailedToParseRemoteName(_ message: String) case FailedToParseRemoteName(_ message: String)
case VMTerminationFailed(_ message: String) case VMTerminationFailed(_ message: String)
case ImproperlyFormattedHost(_ host: String, _ hint: String) case ImproperlyFormattedHost(_ host: String, _ hint: String)
@@ -105,6 +108,8 @@ extension RuntimeError : CustomStringConvertible {
return message return message
case .PIDLockFailed(let message): case .PIDLockFailed(let message):
return message return message
case .PIDLockMissing(let message):
return message
case .FailedToParseRemoteName(let cause): case .FailedToParseRemoteName(let cause):
return "failed to parse remote name: \(cause)" return "failed to parse remote name: \(cause)"
case .VMTerminationFailed(let message): case .VMTerminationFailed(let message):
+10 -3
View File
@@ -140,7 +140,7 @@ class VMStorageOCI: PrunableStorage {
try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir } try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir }
} }
func pull(_ name: RemoteName, registry: Registry, concurrency: UInt) async throws { func pull(_ name: RemoteName, registry: Registry, concurrency: UInt, deduplicate: Bool) async throws {
SentrySDK.configureScope { scope in SentrySDK.configureScope { scope in
scope.setContext(value: ["imageName": name.description], key: "OCI") scope.setContext(value: ["imageName": name.description], key: "OCI")
} }
@@ -203,10 +203,14 @@ class VMStorageOCI: PrunableStorage {
if let llc = localLayerCache { if let llc = localLayerCache {
let deduplicatedHuman = ByteCountFormatter.string(fromByteCount: Int64(llc.deduplicatedBytes), countStyle: .file) let deduplicatedHuman = ByteCountFormatter.string(fromByteCount: Int64(llc.deduplicatedBytes), countStyle: .file)
defaultLogger.appendNewLine("found an image \(llc.name) that will allow us to deduplicate \(deduplicatedHuman), using it as a base...") if deduplicate {
defaultLogger.appendNewLine("found an image \(llc.name) that will allow us to deduplicate \(deduplicatedHuman), using it as a base...")
} else {
defaultLogger.appendNewLine("found an image \(llc.name) that will allow us to avoid fetching \(deduplicatedHuman), will try use it...")
}
} }
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency, localLayerCache: localLayerCache) try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency, localLayerCache: localLayerCache, deduplicate: deduplicate)
} recoverFromFailure: { error in } recoverFromFailure: { error in
if error is Retryable { if error is Retryable {
print("Error: \(error.localizedDescription)") print("Error: \(error.localizedDescription)")
@@ -235,6 +239,9 @@ class VMStorageOCI: PrunableStorage {
// are excluded from garbage collection // are excluded from garbage collection
VMDirectory(baseURL: vmURL(name)).markExplicitlyPulled() VMDirectory(baseURL: vmURL(name)).markExplicitlyPulled()
} }
// to explicitly set the image as being accessed so it won't get pruned immediately
_ = try VMStorageOCI().open(name)
} }
func linked(from: RemoteName, to: RemoteName) -> Bool { func linked(from: RemoteName, to: RemoteName) -> Bool {
+18
View File
@@ -90,3 +90,21 @@ Instead of Anka Registry, Tart can work with any OCI-compatible container regist
and scalable experience for distributing virtual machines. and scalable experience for distributing virtual machines.
Tart doesn't yet have an analogue of Anka Controller for managing long living VMs but [soon will be](https://github.com/cirruslabs/tart/issues/372). Tart doesn't yet have an analogue of Anka Controller for managing long living VMs but [soon will be](https://github.com/cirruslabs/tart/issues/372).
## Automatic pruning
`tart pull` and `tart clone` commands check the remaining space available on the volume associated with `TART_HOME` directory (defaults to `~/.tart`) before pulling or cloning anything.
In case there's not enough space to fit the newly pulled or cloned VM image, Tart will remove the least recently accessed VMs from OCI cache and `.ipsw` files from IPSW cache until enough free space is available.
To disable this functionality, set the `TART_NO_AUTO_PRUNE` environment variable either globally:
```shell
export TART_NO_AUTO_PRUNE=
```
...or per `tart pull` and `tart clone` invocation as follows:
```shell
TART_NO_AUTO_PRUNE= tart pull ...
```