mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-01 19:51:10 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
227301436c | ||
|
|
106eb5a2c8 | ||
|
|
10bf706653 | ||
|
|
ff928ad77d | ||
|
|
33b5cfe2ed | ||
|
|
3892cdb00d | ||
|
|
5f2199ef3e | ||
|
|
3f26baa341 |
+2
-2
@@ -8,11 +8,11 @@ task:
|
||||
name: dev-mini
|
||||
resources:
|
||||
tart-vms: 1
|
||||
build_script:
|
||||
- swift build
|
||||
test_script:
|
||||
- swift test
|
||||
integration_test_script:
|
||||
# Build Tart
|
||||
- swift build
|
||||
- codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
||||
- export PATH=$(pwd)/.build/arm64-apple-macosx/debug:$PATH
|
||||
# Run integration tests
|
||||
|
||||
@@ -17,7 +17,7 @@ service — a drop-in replacement for the standard GitHub-hosted runners, offeri
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Many companies are using Tart in their internal setups. Here are a few of them:
|
||||
Many companies are using Tart in their internal setups. Here are just a few of them:
|
||||
|
||||
<p align="center">
|
||||
<a href="https://atlassian.com/" target=_blank>
|
||||
@@ -47,6 +47,9 @@ Many companies are using Tart in their internal setups. Here are a few of them:
|
||||
<a href="https://www.pitsdatarecovery.net/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/PITSGlobalDataRecoveryServices.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://expo.dev/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Expo.png" height="65"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
**Note:** If your company or project is using Tart please consider [sharing with the community](https://github.com/cirruslabs/tart/discussions/857).
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
@@ -12,6 +12,39 @@ var vm: VM?
|
||||
struct IPNotFound: Error {
|
||||
}
|
||||
|
||||
@available(macOS 14, *)
|
||||
extension VZDiskSynchronizationMode {
|
||||
public init(_ description: String) throws {
|
||||
switch description {
|
||||
case "none":
|
||||
self = .none
|
||||
case "full":
|
||||
self = .full
|
||||
case "":
|
||||
self = .full
|
||||
default:
|
||||
throw RuntimeError.VMConfigurationError("unsupported disk synchronization mode: \"\(description)\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension VZDiskImageSynchronizationMode {
|
||||
public init(_ description: String) throws {
|
||||
switch description {
|
||||
case "none":
|
||||
self = .none
|
||||
case "fsync":
|
||||
self = .fsync
|
||||
case "full":
|
||||
self = .full
|
||||
case "":
|
||||
self = .full
|
||||
default:
|
||||
throw RuntimeError.VMConfigurationError("unsupported disk image synchronization mode: \"\(description)\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Run: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Run a VM")
|
||||
|
||||
@@ -140,6 +173,20 @@ struct Run: AsyncParsableCommand {
|
||||
@Flag(help: ArgumentHelp("Restrict network access to the host-only network"))
|
||||
var netHost: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("Set the root disk options (e.g. --root-disk-opts=\"ro\" or --root-disk-opts=\"sync=none\")",
|
||||
discussion: """
|
||||
Options are comma-separated and are as follows:
|
||||
|
||||
* ro — attach the root disk in read-only mode instead of the default read-write (e.g. --root-disk-opts="ro")
|
||||
|
||||
* sync=none — disable data synchronization with the permanent storage to increase performance at the cost of a higher chance of data loss (e.g. --root-disk-opts="sync=none")
|
||||
|
||||
* sync=fsync — enable data synchronization with the permanent storage, but don't ensure that it was actually written (e.g. --root-disk-opts="sync=fsync")
|
||||
|
||||
* sync=full — enable data synchronization with the permanent storage and ensure that it was actually written (e.g. --root-disk-opts="sync=full")
|
||||
""", valueName: "options"))
|
||||
var rootDiskOpts: String = ""
|
||||
|
||||
#if arch(arm64)
|
||||
@Flag(help: ArgumentHelp("Disables audio and entropy devices and switches to only Mac-specific input devices.", discussion: "Useful for running a VM that can be suspended via \"tart suspend\"."))
|
||||
#endif
|
||||
@@ -182,7 +229,7 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
if suspendable {
|
||||
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
if (config.platform is Linux) {
|
||||
if !(config.platform is PlatformSuspendable) {
|
||||
throw ValidationError("You can only suspend macOS VMs")
|
||||
}
|
||||
if dir.count > 0 {
|
||||
@@ -219,8 +266,6 @@ struct Run: AsyncParsableCommand {
|
||||
try Softnet.configureSUIDBitIfNeeded()
|
||||
}
|
||||
|
||||
let additionalDiskAttachments = try additionalDiskAttachments()
|
||||
|
||||
var serialPorts: [VZSerialPortConfiguration] = []
|
||||
if serial {
|
||||
let tty_fd = createPTY()
|
||||
@@ -239,15 +284,19 @@ struct Run: AsyncParsableCommand {
|
||||
serialPorts.append(createSerialPortConfiguration(tty_read!, tty_write!))
|
||||
}
|
||||
|
||||
// Parse root disk options
|
||||
let diskOptions = DiskOptions(rootDiskOpts)
|
||||
|
||||
vm = try VM(
|
||||
vmDir: vmDir,
|
||||
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
|
||||
additionalStorageDevices: additionalDiskAttachments,
|
||||
additionalStorageDevices: try additionalDiskAttachments(),
|
||||
directorySharingDevices: directoryShares() + rosettaDirectoryShare(),
|
||||
serialPorts: serialPorts,
|
||||
suspendable: suspendable,
|
||||
audio: !noAudio,
|
||||
clipboard: !noClipboard
|
||||
clipboard: !noClipboard,
|
||||
sync: VZDiskImageSynchronizationMode(diskOptions.syncModeRaw)
|
||||
)
|
||||
|
||||
let vncImpl: VNC? = try {
|
||||
@@ -455,15 +504,9 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
func additionalDiskAttachments() throws -> [VZStorageDeviceConfiguration] {
|
||||
var result: [VZStorageDeviceConfiguration] = []
|
||||
|
||||
let expandedDiskPaths = disk.map { NSString(string:$0).expandingTildeInPath }
|
||||
|
||||
for rawDisk in expandedDiskPaths {
|
||||
result.append(try AdditionalDisk(parseFrom: rawDisk).configuration)
|
||||
try disk.map {
|
||||
try AdditionalDisk(parseFrom: $0).configuration
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func directoryShares() throws -> [VZDirectorySharingDeviceConfiguration] {
|
||||
@@ -700,18 +743,20 @@ struct AdditionalDisk {
|
||||
throw UnsupportedOSError("attaching Network Block Devices", "are")
|
||||
}
|
||||
|
||||
let syncMode = try parseSyncMode(syncModeRaw)
|
||||
|
||||
let nbdAttachment = try VZNetworkBlockDeviceStorageDeviceAttachment(
|
||||
url: diskURL!,
|
||||
timeout: 30,
|
||||
isForcedReadOnly: diskReadOnly,
|
||||
synchronizationMode: syncMode
|
||||
synchronizationMode: try VZDiskSynchronizationMode(syncModeRaw)
|
||||
)
|
||||
|
||||
return VZVirtioBlockDeviceConfiguration(attachment: nbdAttachment)
|
||||
}
|
||||
|
||||
// Expand the tilde (~) since at this point we're dealing with a local path,
|
||||
// and "expandingTildeInPath" seems to corrupt the remote URLs like nbd://
|
||||
let diskPath = NSString(string: diskPath).expandingTildeInPath
|
||||
|
||||
let diskFileURL = URL(fileURLWithPath: diskPath)
|
||||
|
||||
if pathHasMode(diskPath, mode: S_IFBLK) {
|
||||
@@ -734,7 +779,7 @@ struct AdditionalDisk {
|
||||
}
|
||||
|
||||
let blockAttachment = try VZDiskBlockDeviceStorageDeviceAttachment(fileHandle: FileHandle(fileDescriptor: fd, closeOnDealloc: true),
|
||||
readOnly: diskReadOnly, synchronizationMode: try parseSyncMode(syncModeRaw))
|
||||
readOnly: diskReadOnly, synchronizationMode: try VZDiskSynchronizationMode(syncModeRaw))
|
||||
|
||||
return VZVirtioBlockDeviceConfiguration(attachment: blockAttachment)
|
||||
}
|
||||
@@ -768,7 +813,7 @@ struct AdditionalDisk {
|
||||
url: diskFileURL,
|
||||
readOnly: diskReadOnly,
|
||||
cachingMode: .automatic,
|
||||
synchronizationMode: try Self.parseImageSyncMode(syncModeRaw)
|
||||
synchronizationMode: try VZDiskImageSynchronizationMode(syncModeRaw)
|
||||
)
|
||||
|
||||
return VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment)
|
||||
@@ -776,58 +821,36 @@ struct AdditionalDisk {
|
||||
|
||||
static func parseOptions(_ parseFrom: String) -> (String, Bool, String) {
|
||||
var arguments = parseFrom.split(separator: ":")
|
||||
let options = arguments.last!.split(separator: ",")
|
||||
|
||||
var readOnly: Bool = false
|
||||
var syncModeRaw: String = ""
|
||||
let options = DiskOptions(String(arguments.last!))
|
||||
if options.foundAtLeastOneOption {
|
||||
arguments.removeLast()
|
||||
}
|
||||
|
||||
var foundAtLeastOneOption: Bool = false
|
||||
return (arguments.joined(separator: ":"), options.readOnly, options.syncModeRaw)
|
||||
}
|
||||
}
|
||||
|
||||
struct DiskOptions {
|
||||
var readOnly: Bool = false
|
||||
var syncModeRaw: String = ""
|
||||
var foundAtLeastOneOption: Bool = false
|
||||
|
||||
init(_ parseFrom: String) {
|
||||
let options = parseFrom.split(separator: ",")
|
||||
|
||||
for option in options {
|
||||
switch true {
|
||||
case option == "ro":
|
||||
readOnly = true
|
||||
foundAtLeastOneOption = true
|
||||
self.readOnly = true
|
||||
self.foundAtLeastOneOption = true
|
||||
case option.hasPrefix("sync="):
|
||||
syncModeRaw = String(option.dropFirst("sync=".count))
|
||||
foundAtLeastOneOption = true
|
||||
self.syncModeRaw = String(option.dropFirst("sync=".count))
|
||||
self.foundAtLeastOneOption = true
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if foundAtLeastOneOption {
|
||||
arguments.removeLast()
|
||||
}
|
||||
|
||||
return (arguments.joined(separator: ":"), readOnly, syncModeRaw)
|
||||
}
|
||||
|
||||
@available(macOS 14, *)
|
||||
static func parseSyncMode(_ parseFrom: String) throws -> VZDiskSynchronizationMode {
|
||||
switch parseFrom {
|
||||
case "none":
|
||||
return .none
|
||||
case "full":
|
||||
return .full
|
||||
case "":
|
||||
return .full
|
||||
default:
|
||||
throw RuntimeError.VMConfigurationError("unsupported disk synchronization mode: \"\(parseFrom)\"")
|
||||
}
|
||||
}
|
||||
|
||||
static func parseImageSyncMode(_ parseFrom: String) throws -> VZDiskImageSynchronizationMode {
|
||||
switch parseFrom {
|
||||
case "none":
|
||||
return .none
|
||||
case "full":
|
||||
return .full
|
||||
case "":
|
||||
return .full
|
||||
default:
|
||||
throw RuntimeError.VMConfigurationError("unsupported disk image synchronization mode: \"\(parseFrom)\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
struct LocalLayerCache {
|
||||
struct DigestInfo {
|
||||
let range: Range<Data.Index>
|
||||
let compressedDigest: String
|
||||
let uncompressedContentDigest: String?
|
||||
}
|
||||
|
||||
let name: String
|
||||
let deduplicatedBytes: UInt64
|
||||
let diskURL: URL
|
||||
|
||||
private let mappedDisk: Data
|
||||
private var digestToRange: [String : Range<Data.Index>] = [:]
|
||||
private var digestToRange: [String: DigestInfo] = [:]
|
||||
private var offsetToRange: [UInt64: DigestInfo] = [:]
|
||||
|
||||
init?(_ name: String, _ deduplicatedBytes: UInt64, _ diskURL: URL, _ manifest: OCIManifest) throws {
|
||||
self.name = name
|
||||
@@ -24,17 +31,27 @@ struct LocalLayerCache {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.digestToRange[layer.digest] = Int(offset)..<Int(offset+uncompressedSize)
|
||||
let info = DigestInfo(
|
||||
range: Int(offset)..<Int(offset + uncompressedSize),
|
||||
compressedDigest: layer.digest,
|
||||
uncompressedContentDigest: layer.uncompressedContentDigest()!
|
||||
)
|
||||
self.digestToRange[layer.digest] = info
|
||||
self.offsetToRange[offset] = info
|
||||
|
||||
offset += uncompressedSize
|
||||
}
|
||||
}
|
||||
|
||||
func find(_ digest: String) -> Data? {
|
||||
guard let foundRange = self.digestToRange[digest] else {
|
||||
return nil
|
||||
func findInfo(digest: String, offsetHint: UInt64) -> DigestInfo? {
|
||||
// Layers can have the same digests, for example, empty ones. Let's use the offset hint to make a better guess.
|
||||
if let info = self.offsetToRange[offsetHint], info.compressedDigest == digest {
|
||||
return info
|
||||
}
|
||||
return self.digestToRange[digest]
|
||||
}
|
||||
|
||||
return self.mappedDisk.subdata(in: foundRange)
|
||||
func subdata(_ range: Range<Data.Index>) -> Data {
|
||||
return self.mappedDisk.subdata(in: range)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
import Compression
|
||||
import System
|
||||
import Retry
|
||||
|
||||
class DiskV2: Disk {
|
||||
private static let bufferSizeBytes = 4 * 1024 * 1024
|
||||
@@ -26,8 +27,22 @@ class DiskV2: Disk {
|
||||
// Launch a disk layer pushing task
|
||||
group.addTask {
|
||||
let compressedData = try (data as NSData).compressed(using: .lz4) as Data
|
||||
let compressedDataDigest = Digest.hash(compressedData)
|
||||
|
||||
let layerDigest = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb)
|
||||
try await retry(maxAttempts: 5, backoff: .exponentialWithFullJitter(baseDelay: .seconds(5), maxDelay: .seconds(60))) {
|
||||
if try await !registry.blobExists(compressedDataDigest) {
|
||||
_ = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb, digest: compressedDataDigest)
|
||||
}
|
||||
} recoverFromFailure: { error in
|
||||
if error is URLError {
|
||||
print("Error: \(error.localizedDescription)")
|
||||
print("Attempting to re-try...")
|
||||
|
||||
return .retry
|
||||
}
|
||||
|
||||
return .throw
|
||||
}
|
||||
|
||||
// Update progress using a relative value
|
||||
progress.completedUnitCount += Int64(data.count)
|
||||
@@ -35,7 +50,7 @@ class DiskV2: Disk {
|
||||
return (index, OCIManifestLayer(
|
||||
mediaType: diskV2MediaType,
|
||||
size: compressedData.count,
|
||||
digest: layerDigest,
|
||||
digest: compressedDataDigest,
|
||||
uncompressedSize: UInt64(data.count),
|
||||
uncompressedContentDigest: Digest.hash(data)
|
||||
))
|
||||
@@ -121,11 +136,14 @@ class DiskV2: Disk {
|
||||
// Launch a fetching and decompression task
|
||||
group.addTask {
|
||||
// No need to fetch and decompress anything if we've already done so
|
||||
if try pullResumed && Digest.hash(diskURL, offset: diskWritingOffset, size: uncompressedLayerSize) == uncompressedLayerContentDigest {
|
||||
// Update the progress
|
||||
progress.completedUnitCount += Int64(diskLayer.size)
|
||||
if pullResumed {
|
||||
// do not check hash in the condition above to make it lazy e.g. only do expensive calculations if needed
|
||||
if try Digest.hash(diskURL, offset: diskWritingOffset, size: uncompressedLayerSize) == uncompressedLayerContentDigest {
|
||||
// Update the progress
|
||||
progress.completedUnitCount += Int64(diskLayer.size)
|
||||
|
||||
return
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Open the disk file for writing
|
||||
@@ -140,9 +158,17 @@ class DiskV2: Disk {
|
||||
}
|
||||
|
||||
// Check if we already have this layer contents in the local layer cache
|
||||
if let localLayerCache = localLayerCache, let data = localLayerCache.find(diskLayer.digest), Digest.hash(data) == uncompressedLayerContentDigest {
|
||||
// Fulfil the layer contents from the local blob cache
|
||||
_ = try zeroSkippingWrite(disk, rdisk, fsBlockSize, diskWritingOffset, data)
|
||||
if let localLayerCache = localLayerCache, let localLayerInfo = localLayerCache.findInfo(digest: diskLayer.digest, offsetHint: diskWritingOffset) {
|
||||
// indicates that the locally cloned disk image has the same content at the given offset
|
||||
let localHit = localLayerInfo.uncompressedContentDigest == uncompressedLayerContentDigest
|
||||
&& localLayerInfo.range.lowerBound == diskWritingOffset
|
||||
// doesn't seem that localHit can ever be false if the localLayerCache is not nil
|
||||
// but let's just add extra safety here and check it
|
||||
if !localHit {
|
||||
// Fulfil the layer contents from the local blob cache
|
||||
let data = localLayerCache.subdata(localLayerInfo.range)
|
||||
_ = try zeroSkippingWrite(disk, rdisk, fsBlockSize, diskWritingOffset, data)
|
||||
}
|
||||
try disk.close()
|
||||
|
||||
// Update the progress
|
||||
|
||||
@@ -10,6 +10,7 @@ enum RegistryError: Error {
|
||||
}
|
||||
|
||||
enum HTTPMethod: String {
|
||||
case HEAD = "HEAD"
|
||||
case GET = "GET"
|
||||
case POST = "POST"
|
||||
case PUT = "PUT"
|
||||
@@ -21,6 +22,7 @@ enum HTTPCode: Int {
|
||||
case Created = 201
|
||||
case Accepted = 202
|
||||
case Unauthorized = 401
|
||||
case NotFound = 404
|
||||
}
|
||||
|
||||
extension Data {
|
||||
@@ -189,7 +191,7 @@ class Registry {
|
||||
return URLComponents(url: uploadLocation.absolutize(baseURL), resolvingAgainstBaseURL: true)!
|
||||
}
|
||||
|
||||
public func pushBlob(fromData: Data, chunkSizeMb: Int = 0) async throws -> String {
|
||||
public func pushBlob(fromData: Data, chunkSizeMb: Int = 0, digest: String? = nil) async throws -> String {
|
||||
// Initiate a blob upload
|
||||
let (data, postResponse) = try await dataRequest(.POST, endpointURL("\(namespace)/blobs/uploads/"),
|
||||
headers: ["Content-Length": "0"])
|
||||
@@ -201,7 +203,7 @@ class Registry {
|
||||
// Figure out where to upload the blob
|
||||
var uploadLocation = try uploadLocationFromResponse(postResponse)
|
||||
|
||||
let digest = Digest.hash(fromData)
|
||||
let digest = digest ?? Digest.hash(fromData)
|
||||
|
||||
if chunkSizeMb == 0 {
|
||||
// monolithic upload
|
||||
@@ -249,6 +251,19 @@ class Registry {
|
||||
return digest
|
||||
}
|
||||
|
||||
public func blobExists(_ digest: String) async throws -> Bool {
|
||||
let (data, response) = try await dataRequest(.HEAD, endpointURL("\(namespace)/blobs/\(digest)"))
|
||||
|
||||
switch response.statusCode {
|
||||
case HTTPCode.Ok.rawValue:
|
||||
return true
|
||||
case HTTPCode.NotFound.rawValue:
|
||||
return false
|
||||
default:
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "checking blob", code: response.statusCode, details: data.asText())
|
||||
}
|
||||
}
|
||||
|
||||
public func pullBlob(_ digest: String, handler: (Data) async throws -> Void) async throws {
|
||||
let (channel, response) = try await channelRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"), viaFile: true)
|
||||
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||
|
||||
@@ -8,7 +8,7 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
|
||||
|
||||
#if arch(arm64)
|
||||
|
||||
struct Darwin: Platform {
|
||||
struct Darwin: PlatformSuspendable {
|
||||
var ecid: VZMacMachineIdentifier
|
||||
var hardwareModel: VZMacHardwareModel
|
||||
|
||||
@@ -103,18 +103,32 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
|
||||
func keyboards() -> [VZKeyboardConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
// Mac keyboard is only supported by guests starting with macOS Ventura
|
||||
return [VZMacKeyboardConfiguration()]
|
||||
return [VZUSBKeyboardConfiguration(), VZMacKeyboardConfiguration()]
|
||||
} else {
|
||||
return [VZUSBKeyboardConfiguration()]
|
||||
}
|
||||
}
|
||||
|
||||
func keyboardsSuspendable() -> [VZKeyboardConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
return [VZMacKeyboardConfiguration()]
|
||||
} else {
|
||||
// fallback to the regular configuration
|
||||
return keyboards()
|
||||
}
|
||||
}
|
||||
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||
if #available(macOS 13, *) {
|
||||
// Trackpad is only supported by guests starting with macOS Ventura
|
||||
[VZUSBScreenCoordinatePointingDeviceConfiguration(), VZMacTrackpadConfiguration()]
|
||||
}
|
||||
|
||||
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
return [VZMacTrackpadConfiguration()]
|
||||
} else {
|
||||
// fallback to the regular configuration
|
||||
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
return pointingDevices()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,3 +8,8 @@ protocol Platform: Codable {
|
||||
func keyboards() -> [VZKeyboardConfiguration]
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
||||
}
|
||||
|
||||
protocol PlatformSuspendable: Platform {
|
||||
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration]
|
||||
func keyboardsSuspendable() -> [VZKeyboardConfiguration]
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ extension URL: Prunable {
|
||||
func deduplicatedSizeBytes() throws -> Int {
|
||||
let values = try resourceValues(forKeys: [.totalFileAllocatedSizeKey, .mayShareFileContentKey])
|
||||
// make sure the file's origin file is there and duplication works
|
||||
var dedublicatedSize = 0
|
||||
if values.mayShareFileContent == true {
|
||||
return Int(deduplicatedBytes())
|
||||
}
|
||||
|
||||
+15
-7
@@ -48,7 +48,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
serialPorts: [VZSerialPortConfiguration] = [],
|
||||
suspendable: Bool = false,
|
||||
audio: Bool = true,
|
||||
clipboard: Bool = true
|
||||
clipboard: Bool = true,
|
||||
sync: VZDiskImageSynchronizationMode = .full
|
||||
) throws {
|
||||
name = vmDir.name
|
||||
config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
@@ -66,7 +67,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
serialPorts: serialPorts,
|
||||
suspendable: suspendable,
|
||||
audio: audio,
|
||||
clipboard: clipboard
|
||||
clipboard: clipboard,
|
||||
sync: sync
|
||||
)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
@@ -294,7 +296,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
serialPorts: [VZSerialPortConfiguration],
|
||||
suspendable: Bool = false,
|
||||
audio: Bool = true,
|
||||
clipboard: Bool = true
|
||||
clipboard: Bool = true,
|
||||
sync: VZDiskImageSynchronizationMode = .full
|
||||
) throws -> VZVirtualMachineConfiguration {
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
|
||||
@@ -326,8 +329,13 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
configuration.audioDevices = [soundDeviceConfiguration]
|
||||
|
||||
// Keyboard and mouse
|
||||
configuration.keyboards = vmConfig.platform.keyboards()
|
||||
configuration.pointingDevices = vmConfig.platform.pointingDevices()
|
||||
if suspendable, let platformSuspendable = vmConfig.platform.self as? PlatformSuspendable {
|
||||
configuration.keyboards = platformSuspendable.keyboardsSuspendable()
|
||||
configuration.pointingDevices = platformSuspendable.pointingDevicesSuspendable()
|
||||
} else {
|
||||
configuration.keyboards = vmConfig.platform.keyboards()
|
||||
configuration.pointingDevices = vmConfig.platform.pointingDevices()
|
||||
}
|
||||
|
||||
// Networking
|
||||
configuration.networkDevices = network.attachments().map {
|
||||
@@ -350,8 +358,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
// Storage
|
||||
let attachment: VZDiskImageStorageDeviceAttachment = vmConfig.os == .linux ?
|
||||
// Use "cached" caching mode for virtio drive to prevent fs corruption on linux
|
||||
try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false, cachingMode: .cached, synchronizationMode: .full) :
|
||||
try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)
|
||||
try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false, cachingMode: .cached, synchronizationMode: sync) :
|
||||
try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false, cachingMode: .automatic, synchronizationMode: sync)
|
||||
|
||||
var device: VZStorageDeviceConfiguration
|
||||
if #available(macOS 14, *), vmConfig.os == .linux {
|
||||
|
||||
@@ -208,14 +208,14 @@ class VMStorageOCI: PrunableStorage {
|
||||
|
||||
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency, localLayerCache: localLayerCache)
|
||||
} recoverFromFailure: { error in
|
||||
if error is RuntimeError {
|
||||
return .throw
|
||||
if error is Retryable {
|
||||
print("Error: \(error.localizedDescription)")
|
||||
print("Attempting to re-try...")
|
||||
|
||||
return .retry
|
||||
}
|
||||
|
||||
print("Error: \(error.localizedDescription)")
|
||||
print("Attempting to re-try...")
|
||||
|
||||
return .retry
|
||||
return .throw
|
||||
}
|
||||
try move(digestName, from: tmpVMDir)
|
||||
transaction.finish()
|
||||
@@ -297,7 +297,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
|
||||
// Now, find the best match based on how many bytes we'll deduplicate
|
||||
let choosen = candidates.filter {
|
||||
$0.deduplicatedBytes > 0
|
||||
$0.deduplicatedBytes > 1024 * 1024 * 1024 // save at least 1GB
|
||||
}.max { left, right in
|
||||
return left.deduplicatedBytes < right.deduplicatedBytes
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
Vendored
+88
-102
@@ -85,10 +85,10 @@
|
||||
<!-- landing image -->
|
||||
<div class="tx-landing__image">
|
||||
<dotlottie-player
|
||||
src="/assets/animations/TartLogo.lottie"
|
||||
mode="normal"
|
||||
style="width: 75%; margin: auto"
|
||||
autoplay
|
||||
src="/assets/animations/TartLogo.lottie"
|
||||
mode="normal"
|
||||
style="width: 75%; margin: auto"
|
||||
autoplay
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -114,10 +114,9 @@
|
||||
<header class="md-typeset">
|
||||
<h1 id="virtualization-and-beyond">
|
||||
Virtualization and beyond
|
||||
<a
|
||||
href="#virtualization-and-beyond"
|
||||
class="headerlink"
|
||||
title="Permanent link"
|
||||
<a href="#virtualization-and-beyond"
|
||||
class="headerlink"
|
||||
title="Permanent link"
|
||||
>
|
||||
¶
|
||||
</a>
|
||||
@@ -125,12 +124,11 @@
|
||||
</header>
|
||||
<div class="mdx-spotlight">
|
||||
<figure class="mdx-spotlight__feature">
|
||||
<img
|
||||
src="assets/images/spotlight/virtualization-framework.webp"
|
||||
alt="Apple’s native Virtualization.Framework"
|
||||
loading="lazy"
|
||||
width="500"
|
||||
height="212"
|
||||
<img src="assets/images/spotlight/virtualization-framework.webp"
|
||||
alt="Apple’s native Virtualization.Framework"
|
||||
loading="lazy"
|
||||
width="500"
|
||||
height="212"
|
||||
/>
|
||||
<figcaption class="md-typeset">
|
||||
<h2>Native performance</h2>
|
||||
@@ -144,12 +142,11 @@
|
||||
</figcaption>
|
||||
</figure>
|
||||
<figure class="mdx-spotlight__feature">
|
||||
<img
|
||||
src="assets/images/spotlight/supported-registries.webp"
|
||||
alt="OCI-compatible container registries"
|
||||
loading="lazy"
|
||||
width="500"
|
||||
height="160"
|
||||
<img src="assets/images/spotlight/supported-registries.webp"
|
||||
alt="OCI-compatible container registries"
|
||||
loading="lazy"
|
||||
width="500"
|
||||
height="160"
|
||||
/>
|
||||
<figcaption class="md-typeset">
|
||||
<h2>Remote storage for Virtual Machines</h2>
|
||||
@@ -161,39 +158,39 @@
|
||||
</figcaption>
|
||||
</figure>
|
||||
<figure class="mdx-spotlight__feature">
|
||||
<img
|
||||
src="assets/images/spotlight/github-actions-runners.webp"
|
||||
alt="GitHub Actions Runners"
|
||||
loading="lazy"
|
||||
width="500"
|
||||
height="280"
|
||||
<img src="assets/images/spotlight/github-actions-runners.webp"
|
||||
alt="GitHub Actions Runners"
|
||||
loading="lazy"
|
||||
width="500"
|
||||
height="280"
|
||||
/>
|
||||
<figcaption class="md-typeset">
|
||||
<h2>Seamless integration with your existing automations</h2>
|
||||
<p>
|
||||
Tart integrates with many continuous integration systems, including a dedicated
|
||||
service of on-demand GitHub Actions Runners. With a single line change, you can cut your
|
||||
CI/CD costs by up to <b>30 times</b> by using <a href="https://cirrus-runners.app/">Cirrus Runners</a>
|
||||
CI/CD costs by up to <b>30 times</b> by using <a href="https://cirrus-runners.app/">Cirrus
|
||||
Runners</a>
|
||||
to run your workflows.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<figure class="mdx-spotlight__feature">
|
||||
<div id="lottie-player">
|
||||
<dotlottie-player
|
||||
src="/assets/animations/Orchard.lottie"
|
||||
mode="normal"
|
||||
style="height: 280px; margin: auto"
|
||||
autoplay
|
||||
loop
|
||||
<dotlottie-player src="/assets/animations/Orchard.lottie"
|
||||
mode="normal"
|
||||
style="height: 280px; margin: auto"
|
||||
autoplay
|
||||
loop
|
||||
/>
|
||||
</div>
|
||||
<figcaption class="md-typeset">
|
||||
<h2>Run at scale with <a href="https://github.com/cirruslabs/orchard">Orchard</a></h2>
|
||||
<p>
|
||||
Tart toolset includes Orchard Orchestration — tool to run and manage Tart virtual machines
|
||||
at scale on a cluster of Apple Silicon hosts. An Orchard Cluster exposes a simple REST API to manage
|
||||
thousands virtual machines. Orchard CLI allows accessing remote virtual machines like they run locally.
|
||||
Tart toolset includes Orchard Orchestration — tool to run and manage Tart virtual
|
||||
machines at scale on a cluster of Apple Silicon hosts. An Orchard Cluster exposes a simple REST API to
|
||||
manage thousands virtual machines. Orchard CLI allows accessing remote virtual machines like they run
|
||||
locally.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
@@ -208,38 +205,38 @@
|
||||
<header class="md-typeset">
|
||||
<h1 id="powerhouse">
|
||||
Automation Powerhouse
|
||||
<a
|
||||
href="#powerhouse"
|
||||
class="headerlink"
|
||||
title="Permanent link"
|
||||
<a href="#powerhouse"
|
||||
class="headerlink"
|
||||
title="Permanent link"
|
||||
>
|
||||
¶
|
||||
</a>
|
||||
</h1>
|
||||
</header>
|
||||
<script>
|
||||
fetch("https://api.github.com/repos/cirruslabs/tart/releases?per_page=100")
|
||||
.then((response) => response.json())
|
||||
.then((releases) => {
|
||||
let allDownloads = 0;
|
||||
for (let release of releases) {
|
||||
for (let asset of release.assets) {
|
||||
if (asset && asset.content_type === "application/octet-stream") {
|
||||
allDownloads += asset.download_count || 0
|
||||
}
|
||||
}
|
||||
}
|
||||
let counterElement = document.getElementById('installation-counter');
|
||||
if (counterElement) {
|
||||
// Live installation count is available starting version 1.0.0
|
||||
// Prior Tart was installed a little over 14,000 times, let's count them too
|
||||
let installationPriorV1 = 14
|
||||
counterElement.textContent = (installationPriorV1 + Math.round(allDownloads / 1000)) + ",000"
|
||||
}
|
||||
})
|
||||
fetch("https://api.github.com/repos/cirruslabs/tart/releases?per_page=100")
|
||||
.then((response) => response.json())
|
||||
.then((releases) => {
|
||||
let allDownloads = 0;
|
||||
for (let release of releases) {
|
||||
for (let asset of release.assets) {
|
||||
if (asset && asset.content_type === "application/octet-stream") {
|
||||
allDownloads += asset.download_count || 0
|
||||
}
|
||||
}
|
||||
}
|
||||
let counterElement = document.getElementById('installation-counter');
|
||||
if (counterElement) {
|
||||
// Live installation count is available starting version 1.0.0
|
||||
// Prior Tart was installed a little over 14,000 times, let's count them too
|
||||
let installationPriorV1 = 14
|
||||
counterElement.textContent = (installationPriorV1 + Math.round(allDownloads / 1000)) + ",000"
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<h2>
|
||||
With more than <strong id="installation-counter">25,000</strong> installations to date, Tart has been adopted for various scenarios.
|
||||
With more than <strong id="installation-counter">25,000</strong> installations to date, Tart has been
|
||||
adopted for various scenarios.
|
||||
Its applications range from powering CI/CD pipelines and reproducible local development environments,
|
||||
to helping in the testing of device management systems without actual physical devices.
|
||||
</h2>
|
||||
@@ -254,10 +251,9 @@
|
||||
<header class="md-typeset">
|
||||
<h1 id="what-our-users-say">
|
||||
What our users say
|
||||
<a
|
||||
href="#what-our-users-say"
|
||||
class="headerlink"
|
||||
title="Permanent link"
|
||||
<a href="#what-our-users-say"
|
||||
class="headerlink"
|
||||
title="Permanent link"
|
||||
>
|
||||
¶
|
||||
</a>
|
||||
@@ -265,70 +261,60 @@
|
||||
</header>
|
||||
<div class="mdx-users">
|
||||
<figure class="mdx-users__testimonial">
|
||||
<img
|
||||
src="assets/images/users/mitchell-hashimoto.webp"
|
||||
alt="Mitchell Hashimoto"
|
||||
loading="lazy"
|
||||
width="200"
|
||||
height="200"
|
||||
<img src="assets/images/users/mikhail-tokarev.webp"
|
||||
alt="Mikhail Tokarev"
|
||||
loading="lazy"
|
||||
width="200"
|
||||
height="200"
|
||||
/>
|
||||
<figcaption class="md-typeset">
|
||||
<h2>Mitchell Hashimoto</h2>
|
||||
<h3>
|
||||
<a href="https://www.hashicorp.com/" target="_blank">HashiCorp</a> co-founder
|
||||
Mikhail Tokarev, CTO at <a href="https://codemagic.io/start/" target="_blank">Codemagic</a>
|
||||
</h3>
|
||||
<hr/>
|
||||
<cite>
|
||||
I've been using "Cirrus Runners" since <a href="https://x.com/mitchellh/status/1731071326201561194" target="_blank">that tweet</a> and
|
||||
it has been fantastic. Huge speed increase, huge cost decrease, zero maintenance, exactly what I wanted.
|
||||
Thanks to the minimal overhead of using the Apple Virtualization
|
||||
API, we’ve seen some performance improvements in booting new
|
||||
virtual machines compared with Anka.
|
||||
</cite>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<figure class="mdx-users__testimonial">
|
||||
<img
|
||||
src="assets/images/users/seb-jachec.webp"
|
||||
alt="Sebastian Jachec"
|
||||
loading="lazy"
|
||||
width="200"
|
||||
height="200"
|
||||
<img src="assets/images/users/expo.webp"
|
||||
alt="Expo"
|
||||
loading="lazy"
|
||||
width="200"
|
||||
height="200"
|
||||
/>
|
||||
<figcaption class="md-typeset">
|
||||
<h2>Sebastian Jachec</h2>
|
||||
<h3>
|
||||
Mobile Engineer at
|
||||
<a href="https://daybridge.com/" target="_blank">Daybridge</a>
|
||||
Infrastructure Team at <a href="https://expo.dev/" target="_blank">Expo</a>
|
||||
</h3>
|
||||
<hr/>
|
||||
<cite>
|
||||
It’s been plain-sailing with the
|
||||
<a href="/integrations/github-actions">Cirrus Runners</a> —
|
||||
they’ve been great! They’re consistently 60+%
|
||||
faster on workflows that we previously used Github
|
||||
Actions’ macOS runners for.
|
||||
Tart was the practical way for us to use the Virtualization framework. Cirrus Labs’
|
||||
continued maintenance and support gives us confidence, and it is also important for us
|
||||
to be able to read the source code when we need to understand an abstraction layer below.
|
||||
</cite>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<figure class="mdx-users__testimonial">
|
||||
<img
|
||||
src="assets/images/users/max-lapides.webp"
|
||||
alt="Max Lapides"
|
||||
loading="lazy"
|
||||
width="200"
|
||||
height="200"
|
||||
<img src="assets/images/users/snowflake.webp"
|
||||
alt="Snowflake"
|
||||
loading="lazy"
|
||||
width="200"
|
||||
height="200"
|
||||
/>
|
||||
<figcaption class="md-typeset">
|
||||
<h2>Max Lapides</h2>
|
||||
<h3>
|
||||
Senior Mobile Engineer at
|
||||
<a href="https://www.tonal.com/" target="_blank">Tonal</a>
|
||||
Red Team at <a href="https://www.snowflake.com/" target="_blank">Snowflake</a>
|
||||
</h3>
|
||||
<hr/>
|
||||
<cite>
|
||||
Previously, we were using the GitHub‑hosted macOS runners
|
||||
and our iOS build took ~30 minutes. Now with
|
||||
<a href="/integrations/github-actions">Cirrus Runners</a>, the iOS build only
|
||||
takes ~12 minutes. That’s a huge boost to our productivity,
|
||||
and for only $150/month per runner it is much less expensive too.
|
||||
The Snowflake Red Team had a need for macOS CI/CD and a segmented macOS development
|
||||
environment. We solved this problem and shared our implementation with macOS EC2 and Tart.
|
||||
We also automated this process with Terraform/Packer to simplify the deployment of our
|
||||
infrastructure and machine images.
|
||||
</cite>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
Reference in New Issue
Block a user