mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-01 11:47:20 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8b69de52d | ||
|
|
c4c2bfeded | ||
|
|
b95585b56b | ||
|
|
8d5574ed3f | ||
|
|
457c2bc7db | ||
|
|
4bf9bdd531 | ||
|
|
8e9d61d5f5 | ||
|
|
71d03226fe | ||
|
|
36dab9878d | ||
|
|
f634002813 | ||
|
|
8e79669afb | ||
|
|
2da8bc0fb5 | ||
|
|
2d984ba194 | ||
|
|
50ce44c3eb | ||
|
|
c9e49ceb39 | ||
|
|
6df50e55d8 | ||
|
|
1fd710d00d | ||
|
|
4f6c7e79e1 | ||
|
|
1afb43e85b | ||
|
|
954cac3bee | ||
|
|
3ff4fc34c6 | ||
|
|
e118b42b1f | ||
|
|
f823190039 | ||
|
|
27cadc3f3b | ||
|
|
d4d3852745 |
+16
-12
@@ -1,15 +1,16 @@
|
||||
use_compute_credits: true
|
||||
|
||||
env:
|
||||
XCODE_TAG: 15-beta-5
|
||||
XCODE_TAG: 15
|
||||
|
||||
task:
|
||||
name: Test on Ventura
|
||||
name: Test on Sonoma
|
||||
alias: test
|
||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
||||
persistent_worker:
|
||||
labels:
|
||||
name: dev-mini
|
||||
resources:
|
||||
tart-vms: 1
|
||||
test_script:
|
||||
- swift test
|
||||
integration_test_script:
|
||||
@@ -28,12 +29,19 @@ task:
|
||||
path: "integration-tests/pytest-junit.xml"
|
||||
format: junit
|
||||
|
||||
task:
|
||||
name: Markdown Lint
|
||||
only_if: $CIRRUS_BRANCH != 'gh-pages' && changesInclude('**.md')
|
||||
container:
|
||||
image: node:latest
|
||||
install_script: npm install -g markdownlint-cli
|
||||
lint_script: markdownlint --config=docs/.markdownlint.yml docs/
|
||||
|
||||
task:
|
||||
name: Lint
|
||||
alias: lint
|
||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
|
||||
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
|
||||
lint_script:
|
||||
- swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json .
|
||||
always:
|
||||
@@ -45,9 +53,8 @@ task:
|
||||
only_if: $CIRRUS_TAG == ''
|
||||
name: Build
|
||||
alias: build
|
||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
|
||||
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
|
||||
build_script: swift build --product tart
|
||||
sign_script: codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
||||
binary_artifacts:
|
||||
@@ -59,9 +66,8 @@ task:
|
||||
depends_on:
|
||||
- lint
|
||||
- build
|
||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
|
||||
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
|
||||
env:
|
||||
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||
@@ -95,9 +101,8 @@ task:
|
||||
- lint
|
||||
- test
|
||||
- build
|
||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
|
||||
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
|
||||
env:
|
||||
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||
@@ -142,7 +147,6 @@ task:
|
||||
task:
|
||||
name: Deploy Documentation
|
||||
only_if: $CIRRUS_BRANCH == 'main'
|
||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
||||
container:
|
||||
image: ghcr.io/cirruslabs/mkdocs-material-insiders:latest
|
||||
registry_config: ENCRYPTED[!cf1a0f25325aa75bad3ce6ebc890bc53eb0044c02efa70d8cefb83ba9766275a994b4831706c52630a0692b2fa9cfb9e!]
|
||||
|
||||
@@ -52,6 +52,9 @@ Many more companies are using Tart in their internal setups. Here are a few of t
|
||||
<a href="https://transloadit.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Transloadit.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://uphold.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Uphold.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://www.pitsdatarecovery.net/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/PITSGlobalDataRecoveryServices.png" height="65"/>
|
||||
</a>
|
||||
@@ -65,8 +68,8 @@ Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura)
|
||||
|
||||
```bash
|
||||
brew install cirruslabs/cli/tart
|
||||
tart clone ghcr.io/cirruslabs/macos-ventura-base:latest ventura-base
|
||||
tart run ventura-base
|
||||
tart clone ghcr.io/cirruslabs/macos-sonoma-base:latest sonoma-base
|
||||
tart run sonoma-base
|
||||
```
|
||||
|
||||
Please check the [official documentation](https://tart.run) for more information and/or feel free to use [discussions](https://github.com/cirruslabs/tart/discussions)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.4 KiB |
@@ -4,5 +4,7 @@
|
||||
<dict>
|
||||
<key>com.apple.security.virtualization</key>
|
||||
<true/>
|
||||
<key>com.apple.security.get-task-allow</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -27,10 +27,17 @@ struct Clone: AsyncParsableCommand {
|
||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||
var insecure: Bool = false
|
||||
|
||||
@Option(help: "network concurrency to use when pulling a remote VM from the OCI-compatible registry")
|
||||
var concurrency: UInt = 4
|
||||
|
||||
func validate() throws {
|
||||
if newName.contains("/") {
|
||||
throw ValidationError("<new-name> should be a local name")
|
||||
}
|
||||
|
||||
if concurrency < 1 {
|
||||
throw ValidationError("network concurrency cannot be less than 1")
|
||||
}
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
@@ -40,7 +47,7 @@ struct Clone: AsyncParsableCommand {
|
||||
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
|
||||
// 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)
|
||||
try await ociStorage.pull(remoteName, registry: registry)
|
||||
try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency)
|
||||
}
|
||||
|
||||
let sourceVM = try VMStorageHelper.open(sourceName)
|
||||
|
||||
@@ -20,6 +20,15 @@ struct Pull: AsyncParsableCommand {
|
||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||
var insecure: Bool = false
|
||||
|
||||
@Option(help: "network concurrency to use when pulling a remote VM from the OCI-compatible registry")
|
||||
var concurrency: UInt = 4
|
||||
|
||||
func validate() throws {
|
||||
if concurrency < 1 {
|
||||
throw ValidationError("network concurrency cannot be less than 1")
|
||||
}
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
// Be more liberal when accepting local image as argument,
|
||||
// see https://github.com/cirruslabs/tart/issues/36
|
||||
@@ -34,6 +43,6 @@ struct Pull: AsyncParsableCommand {
|
||||
|
||||
defaultLogger.appendNewLine("pulling \(remoteName)...")
|
||||
|
||||
try await VMStorageOCI().pull(remoteName, registry: registry)
|
||||
try await VMStorageOCI().pull(remoteName, registry: registry, concurrency: concurrency)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ struct Push: AsyncParsableCommand {
|
||||
"""))
|
||||
var chunkSize: Int = 0
|
||||
|
||||
@Option(help: .hidden)
|
||||
var diskFormat: String = "v2"
|
||||
|
||||
@Flag(help: ArgumentHelp("cache pushed images locally",
|
||||
discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
|
||||
var populateCache: Bool = false
|
||||
@@ -69,7 +72,8 @@ struct Push: AsyncParsableCommand {
|
||||
pushedRemoteName = try await localVMDir.pushToRegistry(
|
||||
registry: registry,
|
||||
references: references,
|
||||
chunkSizeMb: chunkSize
|
||||
chunkSizeMb: chunkSize,
|
||||
diskFormat: diskFormat
|
||||
)
|
||||
// Populate the local cache (if requested)
|
||||
if populateCache {
|
||||
|
||||
+146
-52
@@ -1,5 +1,6 @@
|
||||
import ArgumentParser
|
||||
import Cocoa
|
||||
import Darwin
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
import Virtualization
|
||||
@@ -51,10 +52,17 @@ struct Run: AsyncParsableCommand {
|
||||
var vncExperimental: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("""
|
||||
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\")
|
||||
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\" --disk=\"/dev/disk0\")
|
||||
""", discussion: """
|
||||
Can be either a disk image file or a block device like a local SSD on AWS EC2 Mac instances.
|
||||
|
||||
Learn how to create a disk image using Disk Utility here:
|
||||
https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac
|
||||
|
||||
To work with block devices 'tart' binary must be executed as root which affects locating Tart VMs.
|
||||
To workaround this issue pass TART_HOME explicitly:
|
||||
|
||||
sudo TART_HOME="$HOME/.tart" tart run sonoma --disk=/dev/disk0
|
||||
""", valueName: "path[:ro]"))
|
||||
var disk: [String] = []
|
||||
|
||||
@@ -118,6 +126,12 @@ struct Run: AsyncParsableCommand {
|
||||
if try vmDir.state() == "suspended" {
|
||||
suspendable = true
|
||||
}
|
||||
|
||||
if suspendable {
|
||||
if dir.count > 0 {
|
||||
throw ValidationError("Suspending VMs with shared directories is not supported")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -146,20 +160,6 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
let additionalDiskAttachments = try additionalDiskAttachments()
|
||||
|
||||
// Error out if the disk is locked by the host (e.g. it was mounted in Finder),
|
||||
// see https://github.com/cirruslabs/tart/issues/323 for more details.
|
||||
for additionalDiskAttachment in additionalDiskAttachments {
|
||||
// Read-only attachments do not seem to acquire the lock
|
||||
if additionalDiskAttachment.isReadOnly {
|
||||
continue
|
||||
}
|
||||
|
||||
if try !FileLock(lockURL: additionalDiskAttachment.url).trylock() {
|
||||
throw RuntimeError.DiskAlreadyInUse("disk \(additionalDiskAttachment.url.path) seems to be already in use, "
|
||||
+ "unmount it first in Finder")
|
||||
}
|
||||
}
|
||||
|
||||
var serialPorts: [VZSerialPortConfiguration] = []
|
||||
if serial {
|
||||
let tty_fd = createPTY()
|
||||
@@ -181,7 +181,7 @@ struct Run: AsyncParsableCommand {
|
||||
vm = try VM(
|
||||
vmDir: vmDir,
|
||||
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
|
||||
additionalDiskAttachments: additionalDiskAttachments,
|
||||
additionalStorageDevices: additionalDiskAttachments,
|
||||
directorySharingDevices: directoryShares() + rosettaDirectoryShare(),
|
||||
serialPorts: serialPorts,
|
||||
suspendable: suspendable
|
||||
@@ -331,7 +331,7 @@ struct Run: AsyncParsableCommand {
|
||||
return try Softnet(vmMACAddress: config.macAddress.string)
|
||||
}
|
||||
|
||||
if netBridged.count > 0 {
|
||||
if netBridged.count > 0 {
|
||||
func findBridgedInterface(_ name: String) throws -> VZBridgedNetworkInterface {
|
||||
let interface = VZBridgedNetworkInterface.networkInterfaces.first { interface in
|
||||
interface.identifier == name || interface.localizedDisplayName == name
|
||||
@@ -361,22 +361,43 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
func additionalDiskAttachments() throws -> [VZDiskImageStorageDeviceAttachment] {
|
||||
var result: [VZDiskImageStorageDeviceAttachment] = []
|
||||
func additionalDiskAttachments() throws -> [VZStorageDeviceConfiguration] {
|
||||
var result: [VZStorageDeviceConfiguration] = []
|
||||
let readOnlySuffix = ":ro"
|
||||
let expandedDiskPaths = disk.map { NSString(string:$0).expandingTildeInPath }
|
||||
|
||||
for rawDisk in expandedDiskPaths {
|
||||
if rawDisk.hasSuffix(readOnlySuffix) {
|
||||
result.append(try VZDiskImageStorageDeviceAttachment(
|
||||
url: URL(fileURLWithPath: String(rawDisk.prefix(rawDisk.count - readOnlySuffix.count))),
|
||||
readOnly: true
|
||||
))
|
||||
let diskReadOnly = rawDisk.hasSuffix(readOnlySuffix)
|
||||
let diskPath = diskReadOnly ? String(rawDisk.prefix(rawDisk.count - readOnlySuffix.count)) : rawDisk
|
||||
let diskURL = URL(fileURLWithPath: diskPath)
|
||||
|
||||
// check if `diskPath` is a block device or a directory
|
||||
if pathHasMode(diskPath, mode: S_IFBLK) || pathHasMode(diskPath, mode: S_IFDIR) {
|
||||
print("Using block device\n")
|
||||
guard #available(macOS 14, *) else {
|
||||
throw UnsupportedOSError("attaching block devices", "are")
|
||||
}
|
||||
let fileHandle = FileHandle(forUpdatingAtPath: diskPath)
|
||||
guard fileHandle != nil else {
|
||||
if ProcessInfo.processInfo.userName != "root" {
|
||||
throw RuntimeError.VMConfigurationError("need to run as root to work with block devices")
|
||||
}
|
||||
throw RuntimeError.VMConfigurationError("block device \(diskURL.url.path) seems to be already in use, unmount it first via 'diskutil unmount'")
|
||||
}
|
||||
let attachment = try VZDiskBlockDeviceStorageDeviceAttachment(fileHandle: fileHandle!, readOnly: diskReadOnly, synchronizationMode: .full)
|
||||
result.append(VZVirtioBlockDeviceConfiguration(attachment: attachment))
|
||||
} else {
|
||||
result.append(try VZDiskImageStorageDeviceAttachment(
|
||||
url: URL(fileURLWithPath: rawDisk),
|
||||
readOnly: false
|
||||
))
|
||||
// Error out if the disk is locked by the host (e.g. it was mounted in Finder),
|
||||
// see https://github.com/cirruslabs/tart/issues/323 for more details.
|
||||
if try !diskReadOnly && !FileLock(lockURL: diskURL).trylock() {
|
||||
throw RuntimeError.DiskAlreadyInUse("disk \(diskURL.url.path) seems to be already in use, unmount it first in Finder")
|
||||
}
|
||||
|
||||
let diskImageAttachment = try VZDiskImageStorageDeviceAttachment(
|
||||
url: diskURL,
|
||||
readOnly: diskReadOnly
|
||||
)
|
||||
result.append(VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,13 +429,13 @@ struct Run: AsyncParsableCommand {
|
||||
let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: automountTag)
|
||||
if allNamedShares {
|
||||
var directories: [String : VZSharedDirectory] = Dictionary()
|
||||
directoryShares.forEach { directories[$0.name!] = VZSharedDirectory(url: $0.path, readOnly: $0.readOnly) }
|
||||
try directoryShares.forEach { directories[$0.name!] = try $0.createConfiguration() }
|
||||
sharingDevice.share = VZMultipleDirectoryShare(directories: directories)
|
||||
} else if dir.count > 1 {
|
||||
throw ValidationError("invalid --dir syntax: for multiple directory shares each one of them should be named")
|
||||
} else if dir.count == 1 {
|
||||
let directoryShare = directoryShares.first!
|
||||
let singleDirectoryShare = VZSingleDirectoryShare(directory: VZSharedDirectory(url: directoryShare.path, readOnly: directoryShare.readOnly))
|
||||
let singleDirectoryShare = VZSingleDirectoryShare(directory: try directoryShare.createConfiguration())
|
||||
sharingDevice.share = singleDirectoryShare
|
||||
}
|
||||
|
||||
@@ -589,36 +610,109 @@ struct DirectoryShare {
|
||||
let readOnly: Bool
|
||||
|
||||
init(parseFrom: String) throws {
|
||||
let splits = parseFrom.split(maxSplits: 2) { $0 == ":" }
|
||||
let readOnlySuffix = ":ro"
|
||||
readOnly = parseFrom.hasSuffix(readOnlySuffix)
|
||||
let maybeNameAndURL = readOnly ? String(parseFrom.dropLast(readOnlySuffix.count)) : parseFrom
|
||||
|
||||
if splits.count == 3 {
|
||||
if splits[2] == "ro" {
|
||||
readOnly = true
|
||||
} else {
|
||||
throw ValidationError("invalid --dir syntax: optional read-only specifier can only be \"ro\"")
|
||||
}
|
||||
if maybeNameAndURL.starts(with: "https://") || maybeNameAndURL.starts(with: "http://") {
|
||||
// just a URL
|
||||
name = nil
|
||||
path = URL(string: maybeNameAndURL)!
|
||||
return
|
||||
}
|
||||
|
||||
let splits = maybeNameAndURL.split(separator: ":", maxSplits: 1)
|
||||
|
||||
if splits.count == 2 {
|
||||
name = String(splits[0])
|
||||
path = String(splits[1]).toFilePathURL()
|
||||
} else if splits.count == 2 {
|
||||
if splits[1] == "ro" {
|
||||
name = nil
|
||||
path = String(splits[0]).toFilePathURL()
|
||||
readOnly = true
|
||||
} else {
|
||||
name = String(splits[0])
|
||||
path = String(splits[1]).toFilePathURL()
|
||||
readOnly = false
|
||||
}
|
||||
path = String(splits[1]).toRemoteOrLocalURL()
|
||||
} else {
|
||||
name = nil
|
||||
path = String(splits[0]).toFilePathURL()
|
||||
readOnly = false
|
||||
path = String(splits[0]).toRemoteOrLocalURL()
|
||||
}
|
||||
}
|
||||
|
||||
func createConfiguration() throws -> VZSharedDirectory {
|
||||
if (path.isFileURL) {
|
||||
return VZSharedDirectory(url: path, readOnly: readOnly)
|
||||
}
|
||||
|
||||
let urlCache = URLCache(memoryCapacity: 0, diskCapacity: 1 * 1024 * 1024 * 1024)
|
||||
|
||||
let archiveRequest = URLRequest(url: path, cachePolicy: .returnCacheDataElseLoad)
|
||||
var response: CachedURLResponse? = urlCache.cachedResponse(for: archiveRequest)
|
||||
if (response == nil) {
|
||||
print("Downloading \(path)...")
|
||||
// download and unarchive remote directories if needed here
|
||||
// use old school API to prevent deadlocks since we are running via MainActor
|
||||
let downloadSemaphore = DispatchSemaphore(value: 0)
|
||||
Task {
|
||||
do {
|
||||
let (archiveData, archiveResponse) = try await URLSession.shared.data(for: archiveRequest)
|
||||
urlCache.storeCachedResponse(CachedURLResponse(response: archiveResponse, data: archiveData, storagePolicy: .allowed), for: archiveRequest)
|
||||
print("Cached for future invocations!")
|
||||
} catch {
|
||||
print("Download failed: \(error)")
|
||||
}
|
||||
downloadSemaphore.signal()
|
||||
}
|
||||
downloadSemaphore.wait()
|
||||
response = urlCache.cachedResponse(for: archiveRequest)
|
||||
} else {
|
||||
print("Using cached archive for \(path)...")
|
||||
}
|
||||
|
||||
if (response == nil) {
|
||||
throw ValidationError("Failed to fetch a remote archive!")
|
||||
}
|
||||
|
||||
let temporaryLocation = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString + ".volume")
|
||||
try FileManager.default.createDirectory(atPath: temporaryLocation.path, withIntermediateDirectories: true)
|
||||
let lock = try FileLock(lockURL: temporaryLocation)
|
||||
try lock.lock()
|
||||
|
||||
guard let executableURL = resolveBinaryPath("tar") else {
|
||||
throw ValidationError("tar not found in PATH")
|
||||
}
|
||||
|
||||
let process = Process.init()
|
||||
process.executableURL = executableURL
|
||||
process.currentDirectoryURL = temporaryLocation
|
||||
process.arguments = ["-xz"]
|
||||
|
||||
let inPipe = Pipe()
|
||||
process.standardInput = inPipe
|
||||
process.launch()
|
||||
|
||||
inPipe.fileHandleForWriting.write(response!.data)
|
||||
try inPipe.fileHandleForWriting.close()
|
||||
process.waitUntilExit()
|
||||
|
||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||
throw ValidationError("Unarchiving failed!")
|
||||
}
|
||||
|
||||
print("Unarchived into a temporary directory!")
|
||||
|
||||
return VZSharedDirectory(url: temporaryLocation, readOnly: readOnly)
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
func toFilePathURL() -> URL {
|
||||
URL(fileURLWithPath: NSString(string: self).expandingTildeInPath)
|
||||
func toRemoteOrLocalURL() -> URL {
|
||||
if (starts(with: "https://") || starts(with: "https://")) {
|
||||
URL(string: self)!
|
||||
} else {
|
||||
URL(fileURLWithPath: NSString(string: self).expandingTildeInPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pathHasMode(_ path: String, mode: mode_t) -> Bool {
|
||||
var st = stat()
|
||||
let statRes = stat(path, &st)
|
||||
guard statRes != -1 else {
|
||||
return false
|
||||
}
|
||||
return (Int32(st.st_mode) & Int32(mode)) == Int32(mode)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ class StdinCredentials {
|
||||
return (user, password)
|
||||
}
|
||||
|
||||
private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 255, isSensitive: Bool) throws -> String {
|
||||
private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 1024, isSensitive: Bool) throws -> String {
|
||||
var buf = [CChar](repeating: 0, count: maxCharacters + 1 /* sentinel */ + 1 /* NUL */)
|
||||
guard let rawCredential = readpassphrase(prompt, &buf, buf.count, isSensitive ? RPP_ECHO_OFF : RPP_ECHO_ON) else {
|
||||
throw StdinCredentialsError.CredentialRequired(which: name)
|
||||
|
||||
@@ -35,12 +35,12 @@ class Fetcher {
|
||||
//
|
||||
// This keeps a working reference to that file, yet we don't
|
||||
// have to deal with the cleanup any more.
|
||||
let fh = try FileHandle(forReadingFrom: fileURL)
|
||||
let mappedFile = try Data(contentsOf: fileURL, options: [.alwaysMapped])
|
||||
try FileManager.default.removeItem(at: fileURL)
|
||||
|
||||
Task {
|
||||
while let data = try fh.read(upToCount: 64 * 1024 * 1024) {
|
||||
await dataCh.send(data)
|
||||
for chunk in (0 ..< mappedFile.count).chunks(ofCount: 64 * 1024 * 1024) {
|
||||
await dataCh.send(mappedFile.subdata(in: chunk))
|
||||
}
|
||||
|
||||
dataCh.finish()
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
enum DigestError: Error {
|
||||
case InvalidOffset
|
||||
case InvalidSize
|
||||
}
|
||||
|
||||
class Digest {
|
||||
var hash: SHA256 = SHA256()
|
||||
|
||||
@@ -15,6 +20,37 @@ class Digest {
|
||||
static func hash(_ data: Data) -> String {
|
||||
SHA256.hash(data: data).hexdigest()
|
||||
}
|
||||
|
||||
static func hash(_ url: URL) throws -> String {
|
||||
hash(try Data(contentsOf: url))
|
||||
}
|
||||
|
||||
static func hash(_ url: URL, offset: UInt64, size: UInt64) throws -> String {
|
||||
// Sanity check
|
||||
let fhSanity = try FileHandle(forReadingFrom: url)
|
||||
try fhSanity.seekToEnd()
|
||||
let fileSize = try fhSanity.offset()
|
||||
try fhSanity.close()
|
||||
|
||||
if offset > fileSize {
|
||||
throw DigestError.InvalidOffset
|
||||
}
|
||||
|
||||
if (offset + size) > fileSize {
|
||||
throw DigestError.InvalidSize
|
||||
}
|
||||
|
||||
// Read a chunk of size ``size`` at offset ``offset``
|
||||
// and calculate it's digest
|
||||
let fh = try FileHandle(forReadingFrom: url)
|
||||
defer { try! fh.close() }
|
||||
|
||||
try fh.seek(toOffset: offset)
|
||||
|
||||
let data = try fh.read(upToCount: Int(size))!
|
||||
|
||||
return hash(data)
|
||||
}
|
||||
}
|
||||
|
||||
extension SHA256.Digest {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
protocol Disk {
|
||||
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, progress: Progress) async throws -> [OCIManifestLayer]
|
||||
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress) async throws
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
import Compression
|
||||
|
||||
class DiskV1: Disk {
|
||||
private static let bufferSizeBytes = 4 * 1024 * 1024
|
||||
private static let layerLimitBytes = 500 * 1000 * 1000
|
||||
|
||||
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, progress: Progress) async throws -> [OCIManifestLayer] {
|
||||
var pushedLayers: [OCIManifestLayer] = []
|
||||
|
||||
// Open the disk file
|
||||
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||
var mappedDiskReadOffset = 0
|
||||
|
||||
// Compress the disk file as a single stream
|
||||
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { (length: Int) -> Data? in
|
||||
// Determine the size of the next chunk
|
||||
let bytesRead = min(length, mappedDisk.count - mappedDiskReadOffset)
|
||||
|
||||
// Read the next uncompressed chunk
|
||||
let data = mappedDisk.subdata(in: mappedDiskReadOffset ..< mappedDiskReadOffset + bytesRead)
|
||||
|
||||
// Advance the offset
|
||||
mappedDiskReadOffset += bytesRead
|
||||
|
||||
// Provide the uncompressed chunk to the compressing filter
|
||||
return data
|
||||
}
|
||||
|
||||
// Cut the compressed stream into layers, each equal exactly ``Self.layerLimitBytes`` bytes,
|
||||
// except for the last one, which may be smaller
|
||||
while let compressedData = try compressingFilter.readData(ofLength: Self.layerLimitBytes) {
|
||||
let layerDigest = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb)
|
||||
|
||||
pushedLayers.append(OCIManifestLayer(
|
||||
mediaType: diskV1MediaType,
|
||||
size: compressedData.count,
|
||||
digest: layerDigest
|
||||
))
|
||||
|
||||
// Update progress using an absolute value
|
||||
progress.completedUnitCount = Int64(mappedDiskReadOffset)
|
||||
}
|
||||
|
||||
return pushedLayers
|
||||
}
|
||||
|
||||
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress) async throws {
|
||||
if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
|
||||
throw OCIError.FailedToCreateVmFile
|
||||
}
|
||||
|
||||
// Open the disk file
|
||||
let disk = try FileHandle(forWritingTo: diskURL)
|
||||
defer { try! disk.close() }
|
||||
|
||||
// Decompress the layers onto the disk in a single stream
|
||||
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
|
||||
if let data = data {
|
||||
disk.write(data)
|
||||
}
|
||||
}
|
||||
|
||||
for diskLayer in diskLayers {
|
||||
try await registry.pullBlob(diskLayer.digest) { data in
|
||||
try filter.write(data)
|
||||
|
||||
// Update the progress
|
||||
progress.completedUnitCount += Int64(data.count)
|
||||
}
|
||||
}
|
||||
|
||||
try filter.finalize()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import Foundation
|
||||
import Compression
|
||||
|
||||
class DiskV2: Disk {
|
||||
private static let bufferSizeBytes = 4 * 1024 * 1024
|
||||
private static let layerLimitBytes = 500 * 1000 * 1000
|
||||
|
||||
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, progress: Progress) async throws -> [OCIManifestLayer] {
|
||||
var pushedLayers: [OCIManifestLayer] = []
|
||||
|
||||
// Open the disk file
|
||||
let disk = try FileHandle(forReadingFrom: diskURL)
|
||||
|
||||
// Compress the disk file as multiple individually decompressible streams,
|
||||
// each equal ``Self.layerLimitBytes`` bytes or slightly larger due to the
|
||||
// internal compressor's buffer
|
||||
while let (compressedData, uncompressedSize, uncompressedDigest) = try compressNextLayerOfLimitBytesOrMore(disk: disk) {
|
||||
let layerDigest = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb)
|
||||
|
||||
pushedLayers.append(OCIManifestLayer(
|
||||
mediaType: diskV2MediaType,
|
||||
size: compressedData.count,
|
||||
digest: layerDigest,
|
||||
uncompressedSize: uncompressedSize,
|
||||
uncompressedContentDigest: uncompressedDigest
|
||||
))
|
||||
|
||||
// Update progress using a relative value
|
||||
progress.completedUnitCount += Int64(uncompressedSize)
|
||||
}
|
||||
|
||||
return pushedLayers
|
||||
}
|
||||
|
||||
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress) async throws {
|
||||
// Support resumable pulls
|
||||
let pullResumed = FileManager.default.fileExists(atPath: diskURL.path)
|
||||
|
||||
if !pullResumed && !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
|
||||
throw OCIError.FailedToCreateVmFile
|
||||
}
|
||||
|
||||
// Calculate the uncompressed disk size
|
||||
var uncompressedDiskSize: UInt64 = 0
|
||||
|
||||
for layer in diskLayers {
|
||||
guard let uncompressedLayerSize = layer.uncompressedSize() else {
|
||||
throw OCIError.LayerIsMissingUncompressedSizeAnnotation
|
||||
}
|
||||
|
||||
uncompressedDiskSize += uncompressedLayerSize
|
||||
}
|
||||
|
||||
// Truncate the target disk file so that it will be able
|
||||
// to accomodate the uncompressed disk size
|
||||
let disk = try FileHandle(forWritingTo: diskURL)
|
||||
try disk.truncate(atOffset: uncompressedDiskSize)
|
||||
try disk.close()
|
||||
|
||||
// Concurrently fetch and decompress layers
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
var globalDiskWritingOffset: UInt64 = 0
|
||||
|
||||
for (index, diskLayer) in diskLayers.enumerated() {
|
||||
// Respect the concurrency limit
|
||||
if index >= concurrency {
|
||||
try await group.next()
|
||||
}
|
||||
|
||||
// Retrieve layer annotations
|
||||
guard let uncompressedLayerSize = diskLayer.uncompressedSize() else {
|
||||
throw OCIError.LayerIsMissingUncompressedSizeAnnotation
|
||||
}
|
||||
guard let uncompressedLayerContentDigest = diskLayer.uncompressedContentDigest() else {
|
||||
throw OCIError.LayerIsMissingUncompressedDigestAnnotation
|
||||
}
|
||||
|
||||
// Capture the current disk writing offset
|
||||
let diskWritingOffset = globalDiskWritingOffset
|
||||
|
||||
// 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)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Open the disk file at the specific offset
|
||||
let disk = try FileHandle(forWritingTo: diskURL)
|
||||
try disk.seek(toOffset: diskWritingOffset)
|
||||
|
||||
// Pull and decompress a single layer into the specific offset on disk
|
||||
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
|
||||
if let data = data {
|
||||
disk.write(data)
|
||||
}
|
||||
}
|
||||
|
||||
try await registry.pullBlob(diskLayer.digest) { data in
|
||||
try filter.write(data)
|
||||
|
||||
// Update the progress
|
||||
progress.completedUnitCount += Int64(data.count)
|
||||
}
|
||||
|
||||
try filter.finalize()
|
||||
|
||||
try disk.close()
|
||||
}
|
||||
|
||||
globalDiskWritingOffset += uncompressedLayerSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func compressNextLayerOfLimitBytesOrMore(disk: FileHandle) throws -> (Data, UInt64, String)? {
|
||||
var compressedData = Data()
|
||||
var bytesRead: UInt64 = 0
|
||||
let digest = Digest()
|
||||
|
||||
// Create a compressing filter that we will terminate upon
|
||||
// reaching ``Self.layerLimitBytes`` of compressed data
|
||||
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: bufferSizeBytes) { (length: Int) -> Data? in
|
||||
if compressedData.count >= Self.layerLimitBytes {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let uncompressedChunk = try disk.read(upToCount: bufferSizeBytes) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
bytesRead += UInt64(uncompressedChunk.count)
|
||||
digest.update(uncompressedChunk)
|
||||
|
||||
return uncompressedChunk
|
||||
}
|
||||
|
||||
// Retrieve compressed data chunks, but normally no more than ``Self.layerLimitBytes`` bytes
|
||||
while let compressedChunk = try compressingFilter.readData(ofLength: Self.bufferSizeBytes) {
|
||||
compressedData.append(compressedChunk)
|
||||
}
|
||||
|
||||
// Nothing was read this time from the disk,
|
||||
// signal that to the consumer
|
||||
if bytesRead == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return (compressedData, bytesRead, digest.finalize())
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
// OCI manifest and OCI config media types
|
||||
let ociManifestMediaType = "application/vnd.oci.image.manifest.v1+json"
|
||||
let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
|
||||
|
||||
// Annotations
|
||||
// Layer media types
|
||||
let configMediaType = "application/vnd.cirruslabs.tart.config.v1"
|
||||
let diskV1MediaType = "application/vnd.cirruslabs.tart.disk.v1"
|
||||
let diskV2MediaType = "application/vnd.cirruslabs.tart.disk.v2"
|
||||
let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1"
|
||||
|
||||
// Manifest annotations
|
||||
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
|
||||
let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time"
|
||||
|
||||
// Layer annotations
|
||||
let uncompressedSizeAnnotation = "org.cirruslabs.tart.uncompressed-size"
|
||||
let uncompressedContentDigestAnnotation = "org.cirruslabs.tart.uncompressed-content-digest"
|
||||
|
||||
struct OCIManifest: Codable, Equatable {
|
||||
var schemaVersion: Int = 2
|
||||
var mediaType: String = ociManifestMediaType
|
||||
@@ -71,6 +82,37 @@ struct OCIManifestLayer: Codable, Equatable {
|
||||
var mediaType: String
|
||||
var size: Int
|
||||
var digest: String
|
||||
var annotations: Dictionary<String, String>?
|
||||
|
||||
init(mediaType: String, size: Int, digest: String, uncompressedSize: UInt64? = nil, uncompressedContentDigest: String? = nil) {
|
||||
self.mediaType = mediaType
|
||||
self.size = size
|
||||
self.digest = digest
|
||||
|
||||
var annotations: [String: String] = [:]
|
||||
|
||||
if let uncompressedSize = uncompressedSize {
|
||||
annotations[uncompressedSizeAnnotation] = String(uncompressedSize)
|
||||
}
|
||||
|
||||
if let uncompressedContentDigest = uncompressedContentDigest {
|
||||
annotations[uncompressedContentDigestAnnotation] = uncompressedContentDigest
|
||||
}
|
||||
|
||||
self.annotations = annotations
|
||||
}
|
||||
|
||||
func uncompressedSize() -> UInt64? {
|
||||
guard let value = annotations?[uncompressedSizeAnnotation] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return UInt64(value)
|
||||
}
|
||||
|
||||
func uncompressedContentDigest() -> String? {
|
||||
annotations?[uncompressedContentDigestAnnotation]
|
||||
}
|
||||
}
|
||||
|
||||
struct Descriptor: Equatable {
|
||||
|
||||
@@ -242,7 +242,7 @@ class Registry {
|
||||
return digest
|
||||
}
|
||||
|
||||
public func pullBlob(_ digest: String, handler: (Data) throws -> Void) async throws {
|
||||
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 {
|
||||
let body = try await channel.asData().asText()
|
||||
@@ -253,7 +253,7 @@ class Registry {
|
||||
for try await part in channel {
|
||||
try Task.checkCancellation()
|
||||
|
||||
try handler(Data(part))
|
||||
try await handler(part)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ struct Darwin: PlatformSuspendable {
|
||||
let result = VZMacPlatformConfiguration()
|
||||
|
||||
result.machineIdentifier = ecid
|
||||
result.auxiliaryStorage = VZMacAuxiliaryStorage(contentsOf: nvramURL)
|
||||
result.auxiliaryStorage = VZMacAuxiliaryStorage(url: nvramURL)
|
||||
|
||||
if !hardwareModel.isSupported {
|
||||
// At the moment support of M1 chip is not yet dropped in any macOS version
|
||||
@@ -111,7 +111,8 @@ struct Darwin: PlatformSuspendable {
|
||||
if #available(macOS 14, *) {
|
||||
return [VZMacKeyboardConfiguration()]
|
||||
} else {
|
||||
return []
|
||||
// fallback to the regular configuration
|
||||
return keyboards()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +122,11 @@ struct Darwin: PlatformSuspendable {
|
||||
}
|
||||
|
||||
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
|
||||
[VZMacTrackpadConfiguration()]
|
||||
if #available(macOS 14, *) {
|
||||
return [VZMacTrackpadConfiguration()]
|
||||
} else {
|
||||
// fallback to the regular configuration
|
||||
return pointingDevices()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
-17
@@ -42,7 +42,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
|
||||
init(vmDir: VMDirectory,
|
||||
network: Network = NetworkShared(),
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
||||
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||
serialPorts: [VZSerialPortConfiguration] = [],
|
||||
suspendable: Bool = false
|
||||
@@ -58,7 +58,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
self.network = network
|
||||
configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
|
||||
nvramURL: vmDir.nvramURL, vmConfig: config,
|
||||
network: network, additionalDiskAttachments: additionalDiskAttachments,
|
||||
network: network, additionalStorageDevices: additionalStorageDevices,
|
||||
directorySharingDevices: directorySharingDevices,
|
||||
serialPorts: serialPorts,
|
||||
suspendable: suspendable
|
||||
@@ -71,9 +71,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
|
||||
static func retrieveIPSW(remoteURL: URL) async throws -> URL {
|
||||
// Check if we already have this IPSW in cache
|
||||
let (channel, response) = try await Fetcher.fetch(URLRequest(url: remoteURL), viaFile: true)
|
||||
var headRequest = URLRequest(url: remoteURL)
|
||||
headRequest.httpMethod = "HEAD"
|
||||
let (_, headResponse) = try await Fetcher.fetch(headRequest, viaFile: false)
|
||||
|
||||
if let hash = response.value(forHTTPHeaderField: "x-amz-meta-digest-sha256") {
|
||||
if let hash = headResponse.value(forHTTPHeaderField: "x-amz-meta-digest-sha256") {
|
||||
let ipswLocation = try IPSWCache().locationFor(fileName: "sha256:\(hash).ipsw")
|
||||
|
||||
if FileManager.default.fileExists(atPath: ipswLocation.path) {
|
||||
@@ -87,6 +89,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
// Download the IPSW
|
||||
defaultLogger.appendNewLine("Fetching \(remoteURL.lastPathComponent)...")
|
||||
|
||||
let (channel, response) = try await Fetcher.fetch(URLRequest(url: remoteURL), viaFile: true)
|
||||
|
||||
let progress = Progress(totalUnitCount: response.expectedContentLength)
|
||||
ProgressObserver(progress).log(defaultLogger)
|
||||
|
||||
@@ -138,7 +142,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
ipswURL: URL,
|
||||
diskSizeGB: UInt16,
|
||||
network: Network = NetworkShared(),
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
||||
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||
serialPorts: [VZSerialPortConfiguration] = []
|
||||
) async throws {
|
||||
@@ -186,7 +190,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
self.network = network
|
||||
configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
||||
vmConfig: config, network: network,
|
||||
additionalDiskAttachments: additionalDiskAttachments,
|
||||
additionalStorageDevices: additionalStorageDevices,
|
||||
directorySharingDevices: directorySharingDevices,
|
||||
serialPorts: serialPorts
|
||||
)
|
||||
@@ -273,7 +277,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
nvramURL: URL,
|
||||
vmConfig: VMConfig,
|
||||
network: Network = NetworkShared(),
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
|
||||
additionalStorageDevices: [VZStorageDeviceConfiguration],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration],
|
||||
serialPorts: [VZSerialPortConfiguration],
|
||||
suspendable: Bool = false
|
||||
@@ -322,11 +326,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
// Storage
|
||||
var attachments = [try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)]
|
||||
attachments.append(contentsOf: additionalDiskAttachments)
|
||||
configuration.storageDevices = attachments.map {
|
||||
VZVirtioBlockDeviceConfiguration(attachment: $0)
|
||||
}
|
||||
var devices: [VZStorageDeviceConfiguration] = [
|
||||
VZVirtioBlockDeviceConfiguration(attachment: try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false))
|
||||
]
|
||||
devices.append(contentsOf: additionalStorageDevices)
|
||||
configuration.storageDevices = devices
|
||||
|
||||
// Entropy
|
||||
if !suspendable {
|
||||
@@ -343,13 +347,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
//
|
||||
// A dummy console device useful for implementing
|
||||
// host feature checks in the guest agent software.
|
||||
let consolePort = VZVirtioConsolePortConfiguration()
|
||||
consolePort.name = "tart-version-\(CI.version)"
|
||||
if !suspendable {
|
||||
let consolePort = VZVirtioConsolePortConfiguration()
|
||||
consolePort.name = "tart-version-\(CI.version)"
|
||||
|
||||
let consoleDevice = VZVirtioConsoleDeviceConfiguration()
|
||||
consoleDevice.ports[0] = consolePort
|
||||
let consoleDevice = VZVirtioConsoleDeviceConfiguration()
|
||||
consoleDevice.ports[0] = consolePort
|
||||
|
||||
configuration.consoleDevices.append(consoleDevice)
|
||||
configuration.consoleDevices.append(consoleDevice)
|
||||
}
|
||||
|
||||
try configuration.validate()
|
||||
|
||||
|
||||
@@ -1,33 +1,30 @@
|
||||
import Foundation
|
||||
import Compression
|
||||
import Sentry
|
||||
|
||||
enum OCIError: Error {
|
||||
case ShouldBeExactlyOneLayer
|
||||
case ShouldBeAtLeastOneLayer
|
||||
case FailedToCreateVmFile
|
||||
case LayerIsMissingUncompressedSizeAnnotation
|
||||
case LayerIsMissingUncompressedDigestAnnotation
|
||||
}
|
||||
|
||||
extension VMDirectory {
|
||||
private static let bufferSizeBytes = 64 * 1024 * 1024
|
||||
private static let layerLimitBytes = 500 * 1000 * 1000
|
||||
|
||||
private static let configMediaType = "application/vnd.cirruslabs.tart.config.v1"
|
||||
private static let diskMediaType = "application/vnd.cirruslabs.tart.disk.v1"
|
||||
private static let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1"
|
||||
|
||||
func pullFromRegistry(registry: Registry, reference: String) async throws {
|
||||
func pullFromRegistry(registry: Registry, reference: String, concurrency: UInt) async throws {
|
||||
defaultLogger.appendNewLine("pulling manifest...")
|
||||
|
||||
let (manifest, _) = try await registry.pullManifest(reference: reference)
|
||||
|
||||
return try await pullFromRegistry(registry: registry, manifest: manifest)
|
||||
return try await pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency)
|
||||
}
|
||||
|
||||
func pullFromRegistry(registry: Registry, manifest: OCIManifest) async throws {
|
||||
func pullFromRegistry(registry: Registry, manifest: OCIManifest, concurrency: UInt) async throws {
|
||||
// Pull VM's config file layer and re-serialize it into a config file
|
||||
let configLayers = manifest.layers.filter {
|
||||
$0.mediaType == Self.configMediaType
|
||||
$0.mediaType == configMediaType
|
||||
}
|
||||
if configLayers.count != 1 {
|
||||
throw OCIError.ShouldBeExactlyOneLayer
|
||||
@@ -41,50 +38,36 @@ extension VMDirectory {
|
||||
}
|
||||
try configFile.close()
|
||||
|
||||
// Pull VM's disk layers and decompress them sequentially into a disk file
|
||||
let diskLayers = manifest.layers.filter {
|
||||
$0.mediaType == Self.diskMediaType
|
||||
}
|
||||
if diskLayers.isEmpty {
|
||||
// Pull VM's disk layers and decompress them into a disk file
|
||||
let diskImplType: Disk.Type
|
||||
let layers: [OCIManifestLayer]
|
||||
|
||||
if manifest.layers.contains(where: { $0.mediaType == diskV1MediaType }) {
|
||||
diskImplType = DiskV1.self
|
||||
layers = manifest.layers.filter { $0.mediaType == diskV1MediaType }
|
||||
} else if manifest.layers.contains(where: { $0.mediaType == diskV2MediaType }) {
|
||||
diskImplType = DiskV2.self
|
||||
layers = manifest.layers.filter { $0.mediaType == diskV2MediaType }
|
||||
} else {
|
||||
throw OCIError.ShouldBeAtLeastOneLayer
|
||||
}
|
||||
if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
|
||||
throw OCIError.FailedToCreateVmFile
|
||||
}
|
||||
let disk = try FileHandle(forWritingTo: diskURL)
|
||||
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
|
||||
if let data = data {
|
||||
disk.write(data)
|
||||
}
|
||||
}
|
||||
|
||||
// Progress
|
||||
let diskCompressedSize: Int64 = Int64(diskLayers.map {
|
||||
$0.size
|
||||
}
|
||||
.reduce(0) {
|
||||
$0 + $1
|
||||
})
|
||||
let diskCompressedSize = layers.map { Int64($0.size) }.reduce(0, +)
|
||||
SentrySDK.span?.setMeasurement(name: "compressed_disk_size", value: diskCompressedSize as NSNumber, unit: MeasurementUnitInformation.byte)
|
||||
|
||||
let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0)
|
||||
defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...")
|
||||
|
||||
let progress = Progress(totalUnitCount: diskCompressedSize)
|
||||
ProgressObserver(progress).log(defaultLogger)
|
||||
|
||||
for diskLayer in diskLayers {
|
||||
try await registry.pullBlob(diskLayer.digest) { data in
|
||||
try filter.write(data)
|
||||
progress.completedUnitCount += Int64(data.count)
|
||||
}
|
||||
}
|
||||
try filter.finalize()
|
||||
try disk.close()
|
||||
SentrySDK.span?.setMeasurement(name: "compressed_disk_size", value: diskCompressedSize as NSNumber, unit: MeasurementUnitInformation.byte);
|
||||
try await diskImplType.pull(registry: registry, diskLayers: layers, diskURL: diskURL, concurrency: concurrency, progress: progress)
|
||||
|
||||
// Pull VM's NVRAM file layer and store it in an NVRAM file
|
||||
defaultLogger.appendNewLine("pulling NVRAM...")
|
||||
|
||||
let nvramLayers = manifest.layers.filter {
|
||||
$0.mediaType == Self.nvramMediaType
|
||||
$0.mediaType == nvramMediaType
|
||||
}
|
||||
if nvramLayers.count != 1 {
|
||||
throw OCIError.ShouldBeExactlyOneLayer
|
||||
@@ -99,7 +82,7 @@ extension VMDirectory {
|
||||
try nvram.close()
|
||||
}
|
||||
|
||||
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int) async throws -> RemoteName {
|
||||
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, diskFormat: String) async throws -> RemoteName {
|
||||
var layers = Array<OCIManifestLayer>()
|
||||
|
||||
// Read VM's config and push it as blob
|
||||
@@ -107,32 +90,22 @@ extension VMDirectory {
|
||||
let configJSON = try JSONEncoder().encode(config)
|
||||
defaultLogger.appendNewLine("pushing config...")
|
||||
let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb)
|
||||
layers.append(OCIManifestLayer(mediaType: Self.configMediaType, size: configJSON.count, digest: configDigest))
|
||||
layers.append(OCIManifestLayer(mediaType: configMediaType, size: configJSON.count, digest: configDigest))
|
||||
|
||||
// Progress
|
||||
// Compress the disk file as multiple chunks and push them as disk layers
|
||||
let diskSize = try FileManager.default.attributesOfItem(atPath: diskURL.path)[.size] as! Int64
|
||||
|
||||
defaultLogger.appendNewLine("pushing disk... this will take a while...")
|
||||
let progress = Progress(totalUnitCount: diskSize)
|
||||
ProgressObserver(progress).log(defaultLogger)
|
||||
|
||||
// Read VM's compressed disk as chunks
|
||||
// and sequentially upload them as blobs
|
||||
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||
let mappedDiskSize = mappedDisk.count
|
||||
var mappedDiskReadOffset = 0
|
||||
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { (length: Int) -> Data? in
|
||||
let bytesRead = min(length, mappedDiskSize - mappedDiskReadOffset)
|
||||
let data = mappedDisk.subdata(in: mappedDiskReadOffset ..< mappedDiskReadOffset + bytesRead)
|
||||
mappedDiskReadOffset += bytesRead
|
||||
|
||||
progress.completedUnitCount = Int64(mappedDiskReadOffset)
|
||||
|
||||
return data
|
||||
}
|
||||
while let compressedLayerData = try compressingFilter.readData(ofLength: Self.layerLimitBytes) {
|
||||
let layerDigest = try await registry.pushBlob(fromData: compressedLayerData, chunkSizeMb: chunkSizeMb)
|
||||
layers.append(OCIManifestLayer(mediaType: Self.diskMediaType, size: compressedLayerData.count, digest: layerDigest))
|
||||
switch diskFormat {
|
||||
case "v1":
|
||||
layers.append(contentsOf: try await DiskV1.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, progress: progress))
|
||||
case "v2":
|
||||
layers.append(contentsOf: try await DiskV2.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, progress: progress))
|
||||
default:
|
||||
throw RuntimeError.OCIUnsupportedDiskFormat(diskFormat)
|
||||
}
|
||||
|
||||
// Read VM's NVRAM and push it as blob
|
||||
@@ -140,7 +113,7 @@ extension VMDirectory {
|
||||
|
||||
let nvram = try FileHandle(forReadingFrom: nvramURL).readToEnd()!
|
||||
let nvramDigest = try await registry.pushBlob(fromData: nvram, chunkSizeMb: chunkSizeMb)
|
||||
layers.append(OCIManifestLayer(mediaType: Self.nvramMediaType, size: nvram.count, digest: nvramDigest))
|
||||
layers.append(OCIManifestLayer(mediaType: nvramMediaType, size: nvram.count, digest: nvramDigest))
|
||||
|
||||
// Craft a stub OCI config for Docker Hub compatibility
|
||||
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON()
|
||||
@@ -148,7 +121,7 @@ extension VMDirectory {
|
||||
let manifest = OCIManifest(
|
||||
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
|
||||
layers: layers,
|
||||
uncompressedDiskSize: UInt64(mappedDiskReadOffset),
|
||||
uncompressedDiskSize: UInt64(diskSize),
|
||||
uploadDate: Date()
|
||||
)
|
||||
|
||||
|
||||
@@ -34,9 +34,15 @@ class VMStorageHelper {
|
||||
}
|
||||
}
|
||||
|
||||
extension NSError {
|
||||
func isFileNotFound() -> Bool {
|
||||
return self.code == NSFileNoSuchFileError || self.code == NSFileReadNoSuchFileError
|
||||
}
|
||||
}
|
||||
|
||||
extension Error {
|
||||
func isFileNotFound() -> Bool {
|
||||
(self as NSError).code == NSFileReadNoSuchFileError
|
||||
(self as NSError).isFileNotFound() || (self as NSError).underlyingErrors.contains(where: { $0.isFileNotFound() })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +64,7 @@ enum RuntimeError : Error {
|
||||
case ImportFailed(_ message: String)
|
||||
case SoftnetFailed(_ message: String)
|
||||
case OCIStorageError(_ message: String)
|
||||
case OCIUnsupportedDiskFormat(_ format: String)
|
||||
case SuspendFailed(_ message: String)
|
||||
}
|
||||
|
||||
@@ -102,6 +109,8 @@ extension RuntimeError : CustomStringConvertible {
|
||||
return "Softnet failed: \(message)"
|
||||
case .OCIStorageError(let message):
|
||||
return "OCI storage error: \(message)"
|
||||
case .OCIUnsupportedDiskFormat(let format):
|
||||
return "OCI disk format \(format) is not supported by this version of Tart"
|
||||
case .SuspendFailed(let message):
|
||||
return "Failed to suspend the VM: \(message)"
|
||||
}
|
||||
@@ -111,6 +120,8 @@ extension RuntimeError : CustomStringConvertible {
|
||||
extension RuntimeError : HasExitCode {
|
||||
var exitCode: Int32 {
|
||||
switch self {
|
||||
case .VMDoesNotExist:
|
||||
return 2
|
||||
case .VMNotRunning:
|
||||
return 2
|
||||
case .VMAlreadyRunning:
|
||||
|
||||
@@ -132,7 +132,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir }
|
||||
}
|
||||
|
||||
func pull(_ name: RemoteName, registry: Registry) async throws {
|
||||
func pull(_ name: RemoteName, registry: Registry, concurrency: UInt) async throws {
|
||||
SentrySDK.configureScope { scope in
|
||||
scope.setContext(value: ["imageName": name], key: "OCI")
|
||||
}
|
||||
@@ -188,7 +188,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest)
|
||||
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency)
|
||||
try move(digestName, from: tmpVMDir)
|
||||
transaction.finish()
|
||||
}, onCancel: {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class LayerizerTests: XCTestCase {
|
||||
var registryRunner: RegistryRunner?
|
||||
|
||||
var registry: Registry {
|
||||
registryRunner!.registry
|
||||
}
|
||||
|
||||
override func setUp() async throws {
|
||||
try await super.setUp()
|
||||
|
||||
do {
|
||||
registryRunner = try await RegistryRunner()
|
||||
} catch {
|
||||
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil)
|
||||
}
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
try await super.tearDown()
|
||||
|
||||
registryRunner = nil
|
||||
}
|
||||
|
||||
func testDiskV1() async throws {
|
||||
// Original disk file to be pushed to the registry
|
||||
let originalDiskFileURL = try fileWithRandomData(sizeBytes: 5 * 1024 * 1024 * 1024)
|
||||
addTeardownBlock {
|
||||
try FileManager.default.removeItem(at: originalDiskFileURL)
|
||||
}
|
||||
|
||||
// Disk file to be pulled from the registry
|
||||
// and compared against the original disk file
|
||||
let pulledDiskFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
|
||||
print("pushing disk...")
|
||||
let diskLayers = try await DiskV1.push(diskURL: originalDiskFileURL, registry: registry, chunkSizeMb: 0, progress: Progress())
|
||||
|
||||
print("pulling disk...")
|
||||
try await DiskV1.pull(registry: registry, diskLayers: diskLayers, diskURL: pulledDiskFileURL, concurrency: 16, progress: Progress())
|
||||
|
||||
print("comparing disks...")
|
||||
try XCTAssertEqual(Digest.hash(originalDiskFileURL), Digest.hash(pulledDiskFileURL))
|
||||
}
|
||||
|
||||
func testDiskV2() async throws {
|
||||
// Original disk file to be pushed to the registry
|
||||
let originalDiskFileURL = try fileWithRandomData(sizeBytes: 5 * 1024 * 1024 * 1024)
|
||||
addTeardownBlock {
|
||||
try FileManager.default.removeItem(at: originalDiskFileURL)
|
||||
}
|
||||
|
||||
// Disk file to be pulled from the registry
|
||||
// and compared against the original disk file
|
||||
let pulledDiskFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
|
||||
print("pushing disk...")
|
||||
let diskLayers = try await DiskV2.push(diskURL: originalDiskFileURL, registry: registry, chunkSizeMb: 0, progress: Progress())
|
||||
|
||||
print("pulling disk...")
|
||||
try await DiskV2.pull(registry: registry, diskLayers: diskLayers, diskURL: pulledDiskFileURL, concurrency: 16, progress: Progress())
|
||||
|
||||
print("comparing disks...")
|
||||
try XCTAssertEqual(Digest.hash(originalDiskFileURL), Digest.hash(pulledDiskFileURL))
|
||||
}
|
||||
|
||||
private func fileWithRandomData(sizeBytes: Int) throws -> URL {
|
||||
let devUrandom = try FileHandle(forReadingFrom: URL(filePath: "/dev/urandom"))
|
||||
|
||||
let temporaryFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
FileManager.default.createFile(atPath: temporaryFileURL.path, contents: nil)
|
||||
let temporaryFile = try FileHandle(forWritingTo: temporaryFileURL)
|
||||
|
||||
var remainingBytes = sizeBytes
|
||||
|
||||
while remainingBytes > 0 {
|
||||
let randomData = try devUrandom.read(upToCount: min(64 * 1024 * 1024, remainingBytes))!
|
||||
remainingBytes -= randomData.count
|
||||
try temporaryFile.write(contentsOf: randomData)
|
||||
}
|
||||
|
||||
try devUrandom.close()
|
||||
|
||||
try temporaryFile.close()
|
||||
|
||||
return temporaryFileURL
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
"default": true
|
||||
"MD002": false # First heading should be a top level heading
|
||||
"MD007": # Unordered list indentation
|
||||
indent: 4
|
||||
"MD009": false # Trailing spaces
|
||||
"MD013": false # Line length
|
||||
"MD025": false # Multiple top level headings in the same document
|
||||
"MD026": false # Trailing punctuation in heading
|
||||
"MD033": false # Inline HTML
|
||||
"MD041": false # First line in file should be a top level heading
|
||||
"MD045": false # OK not to have a description for an image
|
||||
"MD046": false # Code block style [Expected: fenced; Actual: indented]
|
||||
@@ -1,8 +1,9 @@
|
||||
edigaryev:
|
||||
name: Nikolay Edigaryev
|
||||
description: Creator
|
||||
avatar: https://github.com/edigaryev.png
|
||||
fkorotkov:
|
||||
name: Fedor Korotkov
|
||||
description: Creator
|
||||
avatar: https://github.com/fkorotkov.png
|
||||
authors:
|
||||
edigaryev:
|
||||
name: Nikolay Edigaryev
|
||||
description: Creator
|
||||
avatar: https://github.com/edigaryev.png
|
||||
fkorotkov:
|
||||
name: Fedor Korotkov
|
||||
description: Creator
|
||||
avatar: https://github.com/fkorotkov.png
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 MiB |
@@ -56,7 +56,7 @@ On bootstrap, each Orchard worker establishes a `Watch()` RPC stream and waits f
|
||||
|
||||
Once `PortForward` instruction is received, the worker connects to the specified VM and port locally and opens a new `PortForward()` RPC stream with the controller, carrying the unique `session` identifier in the gRPC metadata to help distinguish several port forwarding requests.
|
||||
|
||||
We’re using a pretty ingenious Golang package that turns any gRPC stream into a `net.Conn`: https://github.com/mitchellh/go-grpc-net-conn. This allows us to abstract from the gRPC details and simply proxy two `net.Conns`, thus providing the port forwarding functionality.
|
||||
We’re using a pretty ingenious [Golang package that turns any gRPC stream into a `net.Conn`](https://github.com/mitchellh/go-grpc-net-conn). This allows us to abstract from the gRPC details and simply proxy two `net.Conns`, thus providing the port forwarding functionality.
|
||||
|
||||
We’ve also initially considered using [Yamux](https://github.com/hashicorp/yamux) to only keep a single connection with each worker, however, that involves the burden of dealing with flow control and potential implementation bugs associated with it, so we’ve decided to simply open an additional connection for each port forwarding session and let the OS deal with it.
|
||||
|
||||
@@ -74,25 +74,25 @@ Secondly, we’ve exposed three commands in the Orchard CLI that all use this en
|
||||
|
||||
Opens a TCP port locally and forwards everything sent to it to the specified VM (and vice versa).
|
||||
|
||||
For example, `orchard port-forward vm ventura-builder 2222:22` will forward traffic from the local TCP port `2222` to the `ventura-builder` VM’s TCP port `22`.
|
||||
For example, `orchard port-forward vm sonoma-builder 2222:22` will forward traffic from the local TCP port `2222` to the `ventura-builder` VM’s TCP port `22`.
|
||||
|
||||
### `orchard ssh`
|
||||
|
||||
Connects to the specified VM on the default SSH port `22`, optionally only launching a command (if specified), similarly to what the official OpenSSH client does.
|
||||
|
||||
For example, `orchard ssh vm ventura-builder` will open an interactive session with the `ventura-builder` VM.
|
||||
For example, `orchard ssh vm sonoma-builder` will open an interactive session with the `ventura-builder` VM.
|
||||
|
||||
You can also send local scripts for execution by utilizing redirection:
|
||||
|
||||
```shell
|
||||
orchard ssh vm ventura-builder 'sh -s' < script.sh
|
||||
orchard ssh vm sonoma-builder 'sh -s' < script.sh
|
||||
```
|
||||
|
||||
### `orchard vnc`
|
||||
|
||||
Establishes a port forwarding to the specified VM’s default VNC port `5900` and opens the default macOS Screen Sharing app.
|
||||
|
||||
For example, `orchard vnc vm ventura-builder` will establish a port-forwarding to the `ventura-builder` VM's port `5900` under the hood and launch macOS Screen Sharing app.
|
||||
For example, `orchard vnc vm sonoma-builder` will establish a port-forwarding to the `ventura-builder` VM's port `5900` under the hood and launch macOS Screen Sharing app.
|
||||
|
||||
Note that the SSH and VNC commands expect the VM resource to specify credentials in it’s definition (can be done via `orchard create vm`), and will otherwise fall back to the credentials specified by `--username` and `--password`, or if none specified — to de-facto standard of `admin:admin` credentials.
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
draft: false
|
||||
date: 2023-09-20
|
||||
search:
|
||||
exclude: true
|
||||
authors:
|
||||
- fkorotkov
|
||||
categories:
|
||||
- announcement
|
||||
---
|
||||
|
||||
# Tart 2.0.0 and community updates
|
||||
|
||||
Today we'd like to share some news and updates around the Tart ecosystem since the Tart 1.0.0 release back in February.
|
||||
|
||||
<!-- more -->
|
||||
|
||||
## Community Growth
|
||||
|
||||
In the last 7 months Tart community almost tripled and growth is continuing to accelerate. Tart just crossed 25,000 installations,
|
||||
dozens of companies that we know of are using Tart in their daily workflows. If your company is not in the list please consider
|
||||
[joining](https://github.com/cirruslabs/tart/blob/main/Resources/Users/HowToAddYourself.md)!
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
|
||||
</div>
|
||||
|
||||
We are also very pleased by how the community responded to [the license change](2023-02-11-changing-tart-license.md).
|
||||
We now have a number of companies running Tart at scale under the new license. Revenue from the licensing allowed us to
|
||||
allocate time to continue improving Tart which brings us to the section below.
|
||||
|
||||
## Recent updates and what's changing in Tart 2.0.0
|
||||
|
||||
In the last 7 months we've had 12 feature releases that brought a lot of features requested by the community. Here are just
|
||||
a few of them to highlight:
|
||||
|
||||
-[Custom GitLab Runner Executor](/integrations/gitlab-runner/).
|
||||
-[Cluster Management via Orchard](2023-04-25-orchard-ga.md).
|
||||
-Numerous compatibility improvements for all kinds of OCI-registries.
|
||||
-Sonoma Support (see details [below](#macos-sonoma-updates)).
|
||||
|
||||
But one of the most requested features/complaints was around pulling huge Tart images from remote OCI-compatible registries.
|
||||
With an ideal network conditions `tart pull` worked pretty good but in case of any network issues it was required to
|
||||
restart the pull from scratch. Additionally, some registries are notably slow streaming a single blob but can stream
|
||||
multiple blobs in parallel. Finally, the initial format of storing Tart VMs was very naive: disk image is compressed
|
||||
via a single stream which is chunked up into blobs that are serially uploaded to a registry. A single compression stream
|
||||
means that Tart can also only decompress blobs serially.
|
||||
|
||||
Given these three observations above we came up with an improved format of storing Tart VM disk images. In Tart 2.0.0
|
||||
disk images are chunked up first and compressed independently into blobs, when pushed, each blob has attached annotations
|
||||
of expected uncompressed size and a checksum. This way when Tart 2.0.0 is pulling an image pushed by Tart 2.0.0 each blob can
|
||||
be pulled, uncompressed and written at the right offset independently. Having checksums along expected uncompressed blob size
|
||||
also allowed to support resumable pulls. Upon a failure Tart 2.0.0 will compare checksums of chunks and will continue pulling
|
||||
only missing blobs.
|
||||
|
||||
Overall in our experiments we saw a 10% improvement in compressed size of the images and **4 times faster pulls**.
|
||||
|
||||
In order to try the new image format please upgrade Tart and try to pull any of [the Sonoma images](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos-sonoma):
|
||||
|
||||
```bash
|
||||
brew upgrade cirruslabs/cli/tart
|
||||
tart pull ghcr.io/cirruslabs/macos-sonoma-base:latest
|
||||
```
|
||||
|
||||
## macOS Sonoma Updates
|
||||
|
||||
Tart VMs now can be run in a "suspendable" mode which will enable VM snapshotting instead of the standard shutdown.
|
||||
VMs with an existing snapshot will `run` from the same state as they got snapshotted. Please check demo down below:
|
||||
|
||||
<div>
|
||||
<blockquote class="twitter-tweet" data-theme="dark">
|
||||
<p lang="en" dir="ltr">
|
||||
Tart 1.8.0 brings macOS Sonoma updates! 🍏 Now you can suspend and resume your virtual machines for even faster startup times. Check out the demo below 👇 <a href="https://t.co/RoRFT8Nwst">pic.twitter.com/RoRFT8Nwst</a>
|
||||
</p>— Cirrus Labs (@cirrus_labs) <a href="https://twitter.com/cirrus_labs/status/1677308360385765382?ref_src=twsrc%5Etfw">July 7, 2023</a>
|
||||
</blockquote>
|
||||
<script src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
|
||||
</div>
|
||||
|
||||
There are two caveats to the "suspendable" mode support:
|
||||
|
||||
1. Both host and guest should be running macOS Sonoma.
|
||||
2. Snapshots are locally encrypted and can't be shared between physical hosts. Therefore `tart push` won't push the corresponding snapshotted state of the VM.
|
||||
|
||||
Try the "suspendable" mode for yourself by passing `--suspendable` flag to a `tart run` command:
|
||||
|
||||
```bash
|
||||
tart clone ghcr.io/cirruslabs/macos-sonoma-base:latest sonoma-base
|
||||
tart run --suspendable sonoma-base
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
We are very excited about this major release of Tart. Please give it a try and let us know how it went!
|
||||
|
||||
Stay tuned for new updates and announcements! There are a few coming up very shortly...
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
draft: false
|
||||
date: 2023-10-06
|
||||
search:
|
||||
exclude: true
|
||||
authors:
|
||||
- fkorotkov
|
||||
categories:
|
||||
- announcement
|
||||
---
|
||||
|
||||
# Tart is now available on AWS Marketplace
|
||||
|
||||
Announcing [official AMIs for EC2 Mac Instances](https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws)
|
||||
with preconfigured Tart installation that is optimized to work within AWS infrastructure.
|
||||
|
||||
EC2 Mac Instances is a gem of engineering powered by AWS Nitro devices. Just imagine there is a physical Mac Mini with
|
||||
a plugged in Nitro device that can push the physical power button!
|
||||
|
||||

|
||||
|
||||
This clever synergy between Apple Hardware and Nitro System allows seamless integration with VPC networking and booting macOS from an EBS volume.
|
||||
|
||||
In this blog post we’ll see how a virtualization solution like Tart can compliment and elevate experience with EC2 Mac Instances.
|
||||
|
||||
<!-- more -->
|
||||
|
||||
Let’s start from the basics, what EC2 Mac Instances allow to do compared to physical Mac Minis seating in offices of
|
||||
many companies around the world?
|
||||
|
||||
First and foremost, EC2 Mac Instances sit inside AWS data centers and can leverage all the goodies of VPC networking
|
||||
within your company's existing infrastructure. No need to connect your Macs in the office through a VPN and deal
|
||||
with networking and security.
|
||||
|
||||
Additionally, EC2 Mac Instances are booting from EBS volumes which means it is possible to always have reproducible instances
|
||||
and apply all the best practices of Infrastructure-as-Code. Managing a fleet of physical Macs is a pain and it's very hard
|
||||
to make them configured in a reproducible and stable way. With booting from identical EBS volumes your team is always sure
|
||||
about the identical initial state of the fleet.
|
||||
|
||||
## Compromises of EC2 Mac Instances
|
||||
|
||||
The flexibility of EBS volumes for macOS comes with some compromises that virtualization solutions like Tart can help with.
|
||||
The initial boot from an EBS volume takes some time and not instant. macOS itself is pretty heavy and a Nitro device needs
|
||||
to download tens of gigabytes that macOS requires in order to boot. This means that **resetting a EC2 Mac Instance to a clean state
|
||||
is not instant and usually takes a couple of minutes** when you can’t utilize the precious resources for your workloads.
|
||||
|
||||
It is much easier to tailor such EBS volumes with tools like Packer but there is still a **friction to test newly created EBS volumes**
|
||||
since one needs to start and run a EC2 Mac Instance and it’s not possible to test things locally. Similarly it is even harder
|
||||
to test beta versions of macOS that require manual interaction with a running instance.
|
||||
|
||||
## Solution
|
||||
|
||||
Tart can help with all the compromises! Tart virtual machines (VMs) have nearly native performance thanks to utilizing
|
||||
native `Virtualization.Framework` that was developed along the first Apple Silicon chip. **Tart VMs can be copied/disposed
|
||||
instantly and booting a fresh Tart VM takes only several seconds**. It is also possible to run two different Tart VMs in parallel
|
||||
that can have completely different versions of macOS and packages. For example, it is possible to have the latest stable macOS
|
||||
with the release version of Xcode along with the next version of macOS with the latest beta of Xcode.
|
||||
|
||||
Creation of Tart VMs can be automated with [a Packer plugin](https://github.com/cirruslabs/packer-plugin-tart) the same way as
|
||||
creation of EC2 AMIs with one caveat that **Tart Packer Plugin works locally so you can test the same virtual machine locally
|
||||
as you would run it in the cloud**.
|
||||
|
||||
Lightweight nature of Tart VMs with a focus on an easy-to-integrate Tart CLI compliments any macOS automation and helps to reduce
|
||||
the feedback cycle and improves reproducibility of macOS environments even further.
|
||||
|
||||
## Conclusion
|
||||
|
||||
We are excited to bring [official AMIs that include Tart installation optimized to work within AWS](https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws).
|
||||
In the coming weeks when macOS Sonoma will become available on AWS we’ll release another update specifically targeting EC2 Mac Instances.
|
||||
This update will simplify access to local SSDs of Mac Instances that are slightly faster than EBS volumes. Stay tuned and don’t hesitate
|
||||
to ask any [questions](https://tart.run/licensing/).
|
||||
@@ -13,7 +13,7 @@ task:
|
||||
name: hello
|
||||
macos_instance:
|
||||
# can be a remote or a local virtual machine
|
||||
image: ghcr.io/cirruslabs/macos-ventura-base:latest
|
||||
image: ghcr.io/cirruslabs/macos-sonoma-base:latest
|
||||
hello_script:
|
||||
- echo "Hello from within a Tart VM!"
|
||||
- echo "Here is my CPU info:"
|
||||
@@ -45,7 +45,7 @@ exposes it via [`artifacts` instruction](https://cirrus-ci.org/guide/writing-tas
|
||||
task:
|
||||
name: Build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||
image: ghcr.io/cirruslabs/macos-sonoma-xcode:latest
|
||||
build_script: swift build --product tart
|
||||
binary_artifacts:
|
||||
path: .build/debug/tart
|
||||
|
||||
@@ -1,33 +1,107 @@
|
||||
# GitHub Actions
|
||||
# Cirrus Runners for GitHub Actions
|
||||
|
||||
Tart already powers several CI services mentioned above including our own [Cirrus CI](https://cirrus-ci.org/guide/macOS/) which offers unlimited concurrency with per-second billing.
|
||||
For services that haven't leveraged Tart yet, we offer fully managed runners via a monthly subscription.
|
||||
*Cirrus Runners* is the fastest way to get your current CI workflows to benefit from Apple Silicon hardware. No need to manage infrastructure or migrate to another CI provider.
|
||||
Your actions will be executed in clean macOS virtual machines with 4 Apple M2 cores, compared to GitHub's own macOS runners with just 3 cores and only supporting the outdated Apple–Intel architecture.
|
||||
|
||||
## Testimonials from customers
|
||||
|
||||
Sebastian Jachec, Mobile Engineer at [Daybridge](https://www.daybridge.com/).
|
||||
|
||||
> It’s been plain-sailing with the Cirrus Runners — they’ve been great! They’re consistently 60+% faster on workflows that we previously used Github Actions’ macOS runners for.
|
||||
|
||||
Max Lapides, Senior Mobile Engineer at [Tonal](https://www.tonal.com/).
|
||||
Max Lapides, Senior Mobile Engineer at [Tonal](https://www.tonal.com/):
|
||||
|
||||
> Previously, we were using the GitHub‑hosted macOS runners and our iOS build took ~30 minutes. Now with Cirrus Runners, 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.
|
||||
|
||||
John A., Software Engineer at [GitKraken](https://www.gitkraken.com/):
|
||||
|
||||
> GitHub Actions MacOS-x86 runners have become increasingly unreliable, so we're moving our Mac builds over to arm64 because Cirrus Labs' M1 runners are not only ~3 times faster, they've also been far more stable.
|
||||
|
||||
Sebastian Jachec, Mobile Engineer at [Daybridge](https://www.daybridge.com/):
|
||||
|
||||
> It’s been plain-sailing with the Cirrus Runners — they’ve been great! They’re consistently 60+% faster on workflows that we previously used Github Actions’ macOS runners for.
|
||||
|
||||
## Pricing
|
||||
|
||||
Each Cirrus Runner costs $150 a month and there is no limit on the amount of minutes for your actions.
|
||||
We recommend to purchase several Cirrus Runners depending on your team size, so you can run actions in
|
||||
parallel. Note that you can change your subscription at any time via [this page](https://billing.stripe.com/p/login/3cs7vNbzo92p7fy3cc)
|
||||
or by emailing [support@cirruslabs.org](mailto:support@cirruslabs.org).
|
||||
|
||||
### Priority Support
|
||||
|
||||
Subscriptions of 20 or more Cirrus Runners include access to [Priority Support](../licensing.md#priority-support).
|
||||
Please contact [sales@cirruslabs.org](mailto:sales@cirruslabs.org) in order to get all the details.
|
||||
|
||||
### CPU and Memory resources of Cirrus Runners
|
||||
|
||||
By default, a single Cirrus Runner is allocated with 4 M2 cores and 12 GB of unified memory which is enough for most of the workloads.
|
||||
For workloads that require more resources it is possible to use XL Cirrus Runners which have twice the resources: a full M2 chip with 8 cores
|
||||
and 24 GB of unified memory. Note that a single XL Cirrus Runner also uses twice the concurrency.
|
||||
|
||||
In order to use an XL Cirrus Runner for a job please append `-xl` suffix to your `runs-on` property. More on that down below.
|
||||
|
||||
## Installation
|
||||
|
||||
Once you configure [Cirrus Runners App](https://github.com/apps/cirrus-runners) for your organization, you'll be redirected
|
||||
to a checkout page powered by Stripe. During the checkout process you'll be able to configure a subscription for
|
||||
a desired amount of parallel Cirrus Runners and try it for free for 10 days.
|
||||
|
||||
Once configured, please follow instruction below. If you have any questions please contact [support@cirruslabs.org](mailto:support@cirruslabs.org).
|
||||
Subscriptions with more than 10 runners also include Priority Support
|
||||
|
||||
## Configuring Cirrus Runners
|
||||
|
||||
Configuring Cirrus Runners for GitHub Actions is as simple as installing [Cirrus Runners App](https://github.com/apps/cirrus-runners).
|
||||
After successful installation and subscription configuration, use any of [Ventura images managed by us](https://github.com/cirruslabs/macos-image-templates) in `runs-on`:
|
||||
In order for Cirrus Runners to be used by your GitHub Actions workflow jobs, specify a desired image in the `runs-on` property.
|
||||
|
||||
```yaml
|
||||
name: Test Suite
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||
```
|
||||
=== "Default Cirrus Runner"
|
||||
|
||||
```yaml
|
||||
name: Tests
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ghcr.io/cirruslabs/macos-sonoma-xcode:latest
|
||||
```
|
||||
|
||||
=== "XL Cirrus Runner"
|
||||
|
||||
```yaml
|
||||
name: Integration Tests
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ghcr.io/cirruslabs/macos-sonoma-xcode:latest-xl
|
||||
```
|
||||
|
||||
List of all available images can be found in [this repository](https://github.com/cirruslabs/macos-image-templates).
|
||||
|
||||
Note that Tart VM images don't have the same set of pre-installed packages as the official Intel GitHub runners.
|
||||
If something is missing please [create an issue within this repository](https://github.com/cirruslabs/macos-image-templates/issues/new).
|
||||
|
||||
When workflows are executing you'll see Cirrus on-demand runners on your organization's settings page at `https://github.com/organizations/<ORGANIZATION>/settings/actions/runners`.
|
||||
Note that Cirrus Runners will get added to the default runner group. By default, only private repositories can access runners in a default runner group, but you can override this in your organization's settings.
|
||||
Note that Cirrus Runners will get added to the default runner group.
|
||||
|
||||
!!! tip "Using Cirrus Runners with public repositories"
|
||||
|
||||
By default, only private repositories can access runners in a default runner group, but you can override this in your organization's settings:
|
||||
|
||||
```https://github.com/organizations/<YOUR ORGANIZATION NAME>/settings/actions/runner-groups/1```
|
||||
|
||||

|
||||
|
||||
## Data handling flow
|
||||
|
||||
By design Cirrus Runners service never sees any of your secrets or source code and acts as compute platform with the lastest
|
||||
Apple Silicon hardware that can quickly allocate CPU/Memory resources for your jobs.
|
||||
|
||||
Here is a high-level overview of how Cirrus Runners service manages runners for your organization:
|
||||
|
||||
- Cirrus Runner GitHub App is subscribed to [`workflow_job`](https://docs.github.com/en/webhooks/webhook-events-and-payloads#workflow_job).
|
||||
- Upon receiving a new event targeting Cirrus Runners via `runs-on` property the following steps take place:
|
||||
|
||||
- Non-personal information about your job is saved to perform health checking of Cirrus Runners execution.
|
||||
- Cirrus Runners GitHub App has only one permission that allows generating temporary registration tokens for
|
||||
self-hosted GitHub Actions Runners. Note that Cirrus Runners GitHub App itself doesn't have access to contents of
|
||||
repositories in your organization.
|
||||
- Cirrus Runners Service creates a new single use Tart VM, generates a temporary registration tokens for self-hosted runners
|
||||
and passes it without storing inside the VM for the GitHub Actions Runner service to [start a ephemeral runner](https://github.blog/changelog/2021-09-20-github-actions-ephemeral-self-hosted-runners-new-webhooks-for-auto-scaling/).
|
||||
|
||||
- Cirrus Runners service continuously monitors health of the Tart VM executing your job to make sure it runs to completion.
|
||||
- After the job finishes the ephemeral Tart VM is getting destroyed with all the information of the job run.
|
||||
|
||||
If you have any questions or concerns please feel free to reach out to [support@cirruslabs.org](mailto:support@cirruslabs.org).
|
||||
|
||||
@@ -37,7 +37,7 @@ Now you can use Tart Images in your `.gitlab-ci.yml`:
|
||||
```yaml
|
||||
# You can use any remote Tart Image.
|
||||
# Tart Executor will pull it from the registry and use it for creating ephemeral VMs.
|
||||
image: ghcr.io/cirruslabs/macos-ventura-base:latest
|
||||
image: ghcr.io/cirruslabs/macos-sonoma-base:latest
|
||||
|
||||
test:
|
||||
tags:
|
||||
|
||||
@@ -11,8 +11,8 @@ Tart can create VMs from `*.ipsw` files. You can download a specific `*.ipsw` fi
|
||||
use `latest` instead of a path to `*.ipsw` to download the latest available version:
|
||||
|
||||
```bash
|
||||
tart create --from-ipsw=latest ventura-vanilla
|
||||
tart run ventura-vanilla
|
||||
tart create --from-ipsw=latest sonoma-vanilla
|
||||
tart run sonoma-vanilla
|
||||
```
|
||||
|
||||
After the initial booting of the VM you'll need to manually go through the macOS installation process. As a convention we recommend creating an `admin` user with an `admin` password. After the regular installation please do some additional modifications in the VM:
|
||||
@@ -67,8 +67,8 @@ packer {
|
||||
}
|
||||
|
||||
source "tart-cli" "tart" {
|
||||
vm_base_name = "ghcr.io/cirruslabs/macos-ventura-base:latest"
|
||||
vm_name = "my-custom-ventura"
|
||||
vm_base_name = "ghcr.io/cirruslabs/macos-sonoma-base:latest"
|
||||
vm_name = "my-custom-sonoma"
|
||||
cpu_count = 4
|
||||
memory_gb = 8
|
||||
disk_size_gb = 70
|
||||
@@ -92,7 +92,9 @@ Here is a [repository with Packer templates](https://github.com/cirruslabs/macos
|
||||
|
||||
## Working with a Remote OCI Container Registry
|
||||
|
||||
<!-- markdownlint-disable MD034 -->
|
||||
For example, let's say you want to push/pull images to a registry hosted at https://acme.io/.
|
||||
<!-- markdownlint-enable MD034 -->
|
||||
|
||||
### Registry Authorization
|
||||
|
||||
|
||||
+5
-1
@@ -12,7 +12,6 @@ will be required to obtain a paid license.
|
||||
The virtual CPU cores of Tart VMs are not tied to specific physical cores of the host CPU. Instead, for optimal performance
|
||||
Tart VMs will automatically try to balance compute between all available cores of the host CPU. As a result,
|
||||
all performance and energy-efficient cores of the host CPU are always counted towards the license usage.
|
||||
|
||||
|
||||
# License Tiers
|
||||
|
||||
@@ -57,6 +56,11 @@ If your organization is interested in purchasing one of the license tiers, pleas
|
||||
|
||||
You can see a template of a license subscription agreement [here](assets/TartLicenseSubscription.pdf).
|
||||
|
||||
!!! info "Running on AWS?"
|
||||
|
||||
There are [official AMIs for EC2 Mac Instances](https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws)
|
||||
with preconfigured Tart installation that is optimized to work within AWS infrastructure.
|
||||
|
||||
# General Support
|
||||
|
||||
The best way to ask general questions about particular use cases is to email our support team at [support@cirruslabs.org](mailto:support@cirruslabs.org).
|
||||
|
||||
+7
-8
@@ -7,8 +7,8 @@ Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura)
|
||||
|
||||
```bash
|
||||
brew install cirruslabs/cli/tart
|
||||
tart clone ghcr.io/cirruslabs/macos-ventura-base:latest ventura-base
|
||||
tart run ventura-base
|
||||
tart clone ghcr.io/cirruslabs/macos-sonoma-base:latest sonoma-base
|
||||
tart run sonoma-base
|
||||
```
|
||||
|
||||
??? info "Manual installation from a release archive"
|
||||
@@ -17,8 +17,8 @@ tart run ventura-base
|
||||
```bash
|
||||
curl -LO https://github.com/cirruslabs/tart/releases/latest/download/tart.tar.gz
|
||||
tar -xzvf tart.tar.gz
|
||||
./tart.app/Contents/MacOS/tart clone ghcr.io/cirruslabs/macos-ventura-base:latest ventura-base
|
||||
./tart.app/Contents/MacOS/tart run ventura-base
|
||||
./tart.app/Contents/MacOS/tart clone ghcr.io/cirruslabs/macos-sonoma-base:latest sonoma-base
|
||||
./tart.app/Contents/MacOS/tart run sonoma-base
|
||||
```
|
||||
|
||||
Please note that `./tart.app/Contents/MacOS/tart` binary is required to be used in order to trick macOS
|
||||
@@ -33,7 +33,7 @@ tart run ventura-base
|
||||
If the guest VM is running and configured to accept incoming SSH connections you can conveniently connect to it like so:
|
||||
|
||||
```bash
|
||||
ssh admin@$(tart ip ventura-base)
|
||||
ssh admin@$(tart ip sonoma-base)
|
||||
```
|
||||
|
||||
!!! tip "Running scripts inside Tart virtual machines"
|
||||
@@ -42,8 +42,8 @@ ssh admin@$(tart ip ventura-base)
|
||||
|
||||
```bash
|
||||
brew install sshpass
|
||||
sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip ventura-base) "uname -a"
|
||||
sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip ventura-base) < script.sh
|
||||
sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip sonoma-base) "uname -a"
|
||||
sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip sonoma-base) < script.sh
|
||||
```
|
||||
|
||||
## Mounting directories
|
||||
@@ -100,4 +100,3 @@ mount -t virtiofs com.apple.virtio-fs.automount /mnt/shared
|
||||
```
|
||||
|
||||
The directory we've mounted above will be accessible from the `/mnt/shared/project` path inside a guest VM.
|
||||
|
||||
|
||||
Vendored
+7
-4
@@ -1,8 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block announce %}
|
||||
<a href="/blog/2023/04/25/announcing-orchard-orchestration-for-managing-macos-virtual-machines-at-scale/">
|
||||
🚀🚀🚀  Announcing <strong>Orchard</strong> orchestration for managing macOS virtual machines at scale 🚀🚀🚀
|
||||
<a href="/blog/2023/10/06/tart-is-now-available-on-aws-marketplace/">
|
||||
☁️☁️☁️  Tart is now available on <strong>AWS Marketplace</strong> ☁️☁️☁️
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
@@ -242,12 +242,15 @@
|
||||
}
|
||||
let counterElement = document.getElementById('installation-counter');
|
||||
if (counterElement) {
|
||||
counterElement.textContent = Math.round(allDownloads / 1000) + ",000"
|
||||
// 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">10,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>
|
||||
|
||||
@@ -7,7 +7,7 @@ def test_run(tart):
|
||||
vm_name = f"integration-test-run-{uuid.uuid4()}"
|
||||
|
||||
# Instantiate a VM with admin:admin SSH access
|
||||
tart.run(["clone", "ghcr.io/cirruslabs/macos-ventura-base:latest", vm_name])
|
||||
tart.run(["clone", "ghcr.io/cirruslabs/macos-sonoma-base:latest", vm_name])
|
||||
|
||||
# Run the VM asynchronously
|
||||
tart_run_process = tart.run_async(["run", vm_name])
|
||||
|
||||
@@ -82,6 +82,8 @@ markdown_extensions:
|
||||
- pymdownx.tasklist:
|
||||
custom_checkbox: true
|
||||
- pymdownx.tilde
|
||||
- attr_list
|
||||
- md_in_html
|
||||
|
||||
nav:
|
||||
- "Home": index.md
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
|
||||
# helper script to build and run a signed tart binary
|
||||
# usage: ./scripts/run-signed.sh run ventura-base
|
||||
# usage: ./scripts/run-signed.sh run sonoma-base
|
||||
|
||||
set -e
|
||||
|
||||
|
||||
Reference in New Issue
Block a user