Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c7743b7cd | ||
|
|
a40e104c03 | ||
|
|
e2d6c13ed0 | ||
|
|
3f17884ac2 | ||
|
|
7dcebf9c04 | ||
|
|
f6c56ed8eb | ||
|
|
a48f4d4ec9 | ||
|
|
9bb71a4051 | ||
|
|
18d462dd3d | ||
|
|
cd6a97f842 | ||
|
|
3e4bc73ff0 | ||
|
|
89fff42d0a | ||
|
|
7bb50b5e4b | ||
|
|
b5cbf67ee6 | ||
|
|
090a8232fc | ||
|
|
306ace792c | ||
|
|
96f6f94fa7 | ||
|
|
fbc481250d | ||
|
|
35538c2c5d | ||
|
|
4c33064916 | ||
|
|
1a267d4a39 | ||
|
|
36c54d95cb | ||
|
|
1d8bfafde5 | ||
|
|
537f0ae5db | ||
|
|
02f1ff5238 | ||
|
|
9c9bcd586e | ||
|
|
60f0eac7a8 | ||
|
|
35377a3475 | ||
|
|
dda4e91a91 | ||
|
|
ac5f794e6d | ||
|
|
5bcbc77249 | ||
|
|
bf03873c8d | ||
|
|
bad37b129c | ||
|
|
0f47cca746 | ||
|
|
d70eca4484 | ||
|
|
25887b075f | ||
|
|
8c011623be | ||
|
|
2dccdfb306 | ||
|
|
aca768a838 | ||
|
|
68b3557747 | ||
|
|
b2c923f2fe | ||
|
|
c75009e46f | ||
|
|
43e74ab769 | ||
|
|
1338864ed6 | ||
|
|
70040b633c | ||
|
|
f4bc02d175 | ||
|
|
6c24aa639a |
@@ -1,7 +1,7 @@
|
||||
use_compute_credits: true
|
||||
|
||||
env:
|
||||
XCODE_TAG: 15
|
||||
XCODE_TAG: 15.2
|
||||
|
||||
task:
|
||||
name: Test on Sonoma
|
||||
@@ -51,14 +51,18 @@ task:
|
||||
|
||||
task:
|
||||
only_if: $CIRRUS_TAG == ''
|
||||
name: Build
|
||||
env:
|
||||
matrix:
|
||||
BUILD_ARCH: arm64
|
||||
BUILD_ARCH: x86_64
|
||||
name: Build ($BUILD_ARCH)
|
||||
alias: build
|
||||
macos_instance:
|
||||
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
|
||||
build_script: swift build --arch $BUILD_ARCH --product tart
|
||||
sign_script: codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/$BUILD_ARCH-apple-macosx/debug/tart
|
||||
binary_artifacts:
|
||||
path: .build/debug/tart
|
||||
path: .build/$BUILD_ARCH-apple-macosx/debug/tart
|
||||
|
||||
task:
|
||||
only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin')
|
||||
@@ -83,8 +87,9 @@ task:
|
||||
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
|
||||
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
|
||||
install_script:
|
||||
- brew install go goreleaser/tap/goreleaser-pro getsentry/tools/sentry-cli
|
||||
- brew install go goreleaser/tap/goreleaser-pro
|
||||
- brew install mitchellh/gon/gon
|
||||
- curl -sL https://sentry.io/get-cli/ | sh
|
||||
info_script:
|
||||
- security find-identity -v
|
||||
- xcodebuild -version
|
||||
|
||||
@@ -3,23 +3,25 @@ project_name: tart
|
||||
before:
|
||||
hooks:
|
||||
- .ci/set-version.sh
|
||||
- swift build -c release --product tart
|
||||
- swift build --arch x86_64 -c release --product tart
|
||||
- swift build --arch arm64 -c release --product tart
|
||||
- gon gon.hcl
|
||||
- mkdir -p tart.app/Contents/MacOS
|
||||
- cp .build/arm64-apple-macosx/release/tart tart.app/Contents/MacOS/
|
||||
|
||||
builds:
|
||||
- builder: prebuilt
|
||||
- id: tart
|
||||
builder: prebuilt
|
||||
goamd64: [v1]
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
- arm64
|
||||
- amd64
|
||||
binary: tart.app/Contents/MacOS/tart
|
||||
prebuilt:
|
||||
path: tart.app/Contents/MacOS/tart
|
||||
path: '.build/{{- if eq .Arch "arm64" }}arm64{{- else }}x86_64{{ end }}-apple-macosx/release/tart'
|
||||
|
||||
archives:
|
||||
- name_template: "{{ .ProjectName }}"
|
||||
- name_template: "{{ .ProjectName }}-{{ .Arch }}"
|
||||
files:
|
||||
- src: Resources/embedded.provisionprofile
|
||||
dst: tart.app/Contents
|
||||
@@ -31,13 +33,13 @@ release:
|
||||
|
||||
brews:
|
||||
- name: tart
|
||||
tap:
|
||||
repository:
|
||||
owner: cirruslabs
|
||||
name: homebrew-cli
|
||||
caveats: See the GitHub repository for more information
|
||||
homepage: https://github.com/cirruslabs/tart
|
||||
license: "Fair Source"
|
||||
description: Run macOS VMs on Apple Silicon
|
||||
description: Run macOS and Linux VMs on Apple Hardware
|
||||
skip_upload: auto
|
||||
dependencies:
|
||||
- "cirruslabs/cli/softnet"
|
||||
@@ -46,9 +48,3 @@ brews:
|
||||
bin.write_exec_script "#{libexec}/tart.app/Contents/MacOS/tart"
|
||||
custom_block: |
|
||||
depends_on :macos => :ventura
|
||||
|
||||
on_macos do
|
||||
unless Hardware::CPU.arm?
|
||||
odie "Tart only works on Apple Silicon!"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -108,6 +108,15 @@
|
||||
"version" : "0.50.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftradix",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/orchetect/SwiftRadix",
|
||||
"state" : {
|
||||
"revision" : "a52c37a4c213403f7377ae77b4c68451bcab8330",
|
||||
"version" : "1.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "texttable",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -21,6 +21,7 @@ let package = Package(
|
||||
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.8.0"),
|
||||
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
|
||||
.package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.0.0"),
|
||||
.package(url: "https://github.com/orchetect/SwiftRadix", from: "1.3.0")
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(name: "tart", dependencies: [
|
||||
@@ -34,6 +35,7 @@ let package = Package(
|
||||
.product(name: "Sentry", package: "sentry-cocoa"),
|
||||
.product(name: "TextTable", package: "TextTable"),
|
||||
.product(name: "Sysctl", package: "swift-sysctl"),
|
||||
.product(name: "SwiftRadix", package: "SwiftRadix"),
|
||||
], exclude: [
|
||||
"OCI/Reference/Makefile",
|
||||
"OCI/Reference/Reference.g4",
|
||||
|
||||
@@ -8,7 +8,7 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
|
||||
* Use Tart Packer Plugin to automate VM creation.
|
||||
* Easily integrates with any CI system.
|
||||
|
||||
Tart powers [Cirrus Runners](https://tart.run/integrations/github-actions/?utm_source=github&utm_medium=referral)
|
||||
Tart powers [Cirrus Runners](https://cirrus-runners.app/)
|
||||
service — a drop-in replacement for the standard GitHub-hosted runners, offering 2-3 times better performance for a fraction of the price.
|
||||
|
||||
<p align="center">
|
||||
@@ -17,21 +17,7 @@ service — a drop-in replacement for the standard GitHub-hosted runners, offeri
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Tart is also adopted by several other automation services:
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cirrus-ci.org/guide/macOS/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/CirrusCI.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://codemagic.io/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Codemagic.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://testingbot.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/TestingBot.png" height="65"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Many more 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 a few of them:
|
||||
|
||||
<p align="center">
|
||||
<a href="https://ahrefs.com/" target=_blank>
|
||||
@@ -43,6 +29,9 @@ Many more companies are using Tart in their internal setups. Here are a few of t
|
||||
<a href="https://mullvad.net/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Mullvad.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://shape.dk/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/shape.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://suran.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Suran.png" height="65"/>
|
||||
</a>
|
||||
@@ -62,6 +51,12 @@ Many more companies are using Tart in their internal setups. Here are a few of t
|
||||
|
||||
**Note:** If your company or project is using Tart please consider [adding yourself to the list above](/Resources/Users/HowToAddYourself.md).
|
||||
|
||||
<p align="center">
|
||||
<a href="https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws?utm_source=github&utm_medium=referral" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/AWSMarkeplaceLogo.png" height="90"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Usage
|
||||
|
||||
Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura) or later (will download a 25 GB image):
|
||||
|
||||
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
@@ -73,7 +73,7 @@ struct Clone: AsyncParsableCommand {
|
||||
// APFS is doing copy-on-write so the above cloning operation (just copying files on disk)
|
||||
// is not actually claiming new space until the VM is started and it writes something to disk.
|
||||
// So once we clone the VM let's try to claim a little bit of space for the VM to run.
|
||||
try Prune.reclaimIfNeeded(UInt64(sourceVM.sizeBytes()), sourceVM)
|
||||
try Prune.reclaimIfNeeded(UInt64(sourceVM.allocatedSizeBytes()), sourceVM)
|
||||
}, onCancel: {
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import ArgumentParser
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Virtualization
|
||||
|
||||
struct Create: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Create a VM")
|
||||
@@ -22,6 +23,11 @@ struct Create: AsyncParsableCommand {
|
||||
if fromIPSW == nil && !linux {
|
||||
throw ValidationError("Please specify either a --from-ipsw or --linux option!")
|
||||
}
|
||||
#if arch(x86_64)
|
||||
if fromIPSW != nil {
|
||||
throw ValidationError("Only Linux VMs are supported on Intel!")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
@@ -32,19 +38,29 @@ struct Create: AsyncParsableCommand {
|
||||
try tmpVMDirLock.lock()
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
if let fromIPSW = fromIPSW {
|
||||
let ipswURL: URL
|
||||
#if arch(arm64)
|
||||
if let fromIPSW = fromIPSW {
|
||||
let ipswURL: URL
|
||||
|
||||
if fromIPSW == "latest" {
|
||||
ipswURL = try await VM.latestIPSWURL()
|
||||
} else if fromIPSW.starts(with: "http://") || fromIPSW.starts(with: "https://") {
|
||||
ipswURL = URL(string: fromIPSW)!
|
||||
} else {
|
||||
ipswURL = URL(fileURLWithPath: fromIPSW)
|
||||
if fromIPSW == "latest" {
|
||||
defaultLogger.appendNewLine("Looking up the latest supported IPSW...")
|
||||
|
||||
let image = try await withCheckedThrowingContinuation { continuation in
|
||||
VZMacOSRestoreImage.fetchLatestSupported() { result in
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
}
|
||||
|
||||
ipswURL = image.url
|
||||
} else if fromIPSW.starts(with: "http://") || fromIPSW.starts(with: "https://") {
|
||||
ipswURL = URL(string: fromIPSW)!
|
||||
} else {
|
||||
ipswURL = URL(fileURLWithPath: NSString(string: fromIPSW).expandingTildeInPath)
|
||||
}
|
||||
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize)
|
||||
}
|
||||
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize)
|
||||
}
|
||||
#endif
|
||||
|
||||
if linux {
|
||||
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import SystemConfiguration
|
||||
|
||||
struct FQN: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Get a fully-qualified VM name", shouldDisplay: false)
|
||||
|
||||
@Argument(help: "VM name")
|
||||
var name: String
|
||||
|
||||
func run() async throws {
|
||||
if var remoteName = try? RemoteName(name) {
|
||||
let digest = try VMStorageOCI().digest(remoteName)
|
||||
|
||||
remoteName.reference = Reference(digest: digest)
|
||||
|
||||
print(remoteName)
|
||||
} else {
|
||||
print(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ fileprivate struct VMInfo: Encodable {
|
||||
let CPU: Int
|
||||
let Memory: UInt64
|
||||
let Disk: Int
|
||||
let Size: String
|
||||
let Display: String
|
||||
let Running: Bool
|
||||
let State: String
|
||||
@@ -22,10 +23,9 @@ struct Get: AsyncParsableCommand {
|
||||
func run() async throws {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
let diskSizeInGb = try vmDir.sizeGB()
|
||||
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
|
||||
|
||||
let info = VMInfo(CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: diskSizeInGb,
|
||||
let info = VMInfo(CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000),
|
||||
Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state())
|
||||
print(format.renderSingle(info))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import SwiftUI
|
||||
fileprivate struct VMInfo: Encodable {
|
||||
let Source: String
|
||||
let Name: String
|
||||
let Disk: Int
|
||||
let Size: Int
|
||||
let Running: Bool
|
||||
let State: String
|
||||
@@ -37,13 +38,13 @@ struct List: AsyncParsableCommand {
|
||||
|
||||
if source == nil || source == "local" {
|
||||
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
|
||||
try VMInfo(Source: "local", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running(), State: vmDir.state())
|
||||
try VMInfo(Source: "local", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), Running: vmDir.running(), State: vmDir.state())
|
||||
})
|
||||
}
|
||||
|
||||
if source == nil || source == "oci" {
|
||||
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
|
||||
try VMInfo(Source: "oci", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running(), State: vmDir.state())
|
||||
try VMInfo(Source: "oci", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), Running: vmDir.running(), State: vmDir.state())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ struct Prune: AsyncParsableCommand {
|
||||
var prunablesToDelete: [Prunable] = []
|
||||
|
||||
for prunable in prunables {
|
||||
let prunableSizeBytes = UInt64(try prunable.sizeBytes())
|
||||
let prunableSizeBytes = UInt64(try prunable.allocatedSizeBytes())
|
||||
|
||||
if prunableSizeBytes <= spaceBudgetBytes {
|
||||
// Don't mark for deletion as
|
||||
@@ -158,7 +158,7 @@ struct Prune: AsyncParsableCommand {
|
||||
.sorted { try $0.accessDate() < $1.accessDate() }
|
||||
|
||||
// Does it even make sense to start?
|
||||
let cacheUsedBytes = try prunables.map { try $0.sizeBytes() }.reduce(0, +)
|
||||
let cacheUsedBytes = try prunables.map { try $0.allocatedSizeBytes() }.reduce(0, +)
|
||||
if cacheUsedBytes < reclaimBytes {
|
||||
return
|
||||
}
|
||||
@@ -177,9 +177,9 @@ struct Prune: AsyncParsableCommand {
|
||||
continue
|
||||
}
|
||||
|
||||
try SentrySDK.span?.setData(value: prunable.sizeBytes(), key: prunable.url.path)
|
||||
try SentrySDK.span?.setData(value: prunable.allocatedSizeBytes(), key: prunable.url.path)
|
||||
|
||||
cacheReclaimedBytes += try prunable.sizeBytes()
|
||||
cacheReclaimedBytes += try prunable.allocatedSizeBytes()
|
||||
|
||||
try prunable.delete()
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ struct Rename: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
if localStorage.exists(newName) {
|
||||
throw ValidationError("failed to rename VM \(name), target VM \(name) already exists, delete it first!")
|
||||
throw ValidationError("failed to rename VM \(name), target VM \(newName) already exists, delete it first!")
|
||||
}
|
||||
|
||||
try localStorage.rename(name, newName)
|
||||
|
||||
@@ -33,22 +33,31 @@ struct Run: AsyncParsableCommand {
|
||||
))
|
||||
var serialPath: String?
|
||||
|
||||
@Flag(help: "Force open a UI window, even when VNC is enabled.")
|
||||
@Flag(help: ArgumentHelp("Force open a UI window, even when VNC is enabled.", visibility: .hidden))
|
||||
var graphics: Bool = false
|
||||
|
||||
@Flag(help: "Boot into recovery mode")
|
||||
@Flag(help: "Disable audio pass-through to host.")
|
||||
var noAudio: Bool = false
|
||||
|
||||
#if arch(arm64)
|
||||
@Flag(help: "Boot into recovery mode")
|
||||
#endif
|
||||
var recovery: Bool = false
|
||||
|
||||
@Flag(help: ArgumentHelp(
|
||||
"Use screen sharing instead of the built-in UI.",
|
||||
discussion: "Useful since Screen Sharing supports copy/paste, drag and drop, etc.\n"
|
||||
+ "Note that Remote Login option should be enabled inside the VM."))
|
||||
#if arch(arm64)
|
||||
@Flag(help: ArgumentHelp(
|
||||
"Use screen sharing instead of the built-in UI.",
|
||||
discussion: "Useful since Screen Sharing supports copy/paste, drag and drop, etc.\n"
|
||||
+ "Note that Remote Login option should be enabled inside the VM."))
|
||||
#endif
|
||||
var vnc: Bool = false
|
||||
|
||||
@Flag(help: ArgumentHelp(
|
||||
"Use Virtualization.Framework's VNC server instead of the build-in UI.",
|
||||
discussion: "Useful since this type of VNC is available in recovery mode and in macOS installation.\n"
|
||||
+ "Note that this feature is experimental and there may be bugs present when using VNC."))
|
||||
#if arch(arm64)
|
||||
@Flag(help: ArgumentHelp(
|
||||
"Use Virtualization.Framework's VNC server instead of the build-in UI.",
|
||||
discussion: "Useful since this type of VNC is available in recovery mode and in macOS installation.\n"
|
||||
+ "Note that this feature is experimental and there may be bugs present when using VNC."))
|
||||
#endif
|
||||
var vncExperimental: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("""
|
||||
@@ -66,32 +75,35 @@ struct Run: AsyncParsableCommand {
|
||||
""", valueName: "path[:ro]"))
|
||||
var disk: [String] = []
|
||||
|
||||
@Option(name: [.customLong("rosetta")], help: ArgumentHelp(
|
||||
"Attaches a Rosetta share to the guest Linux VM with a specific tag (e.g. --rosetta=\"rosetta\")",
|
||||
discussion: """
|
||||
Requires host to be macOS 13.0 (Ventura) with Rosetta installed. The latter can be done
|
||||
by running "softwareupdate --install-rosetta" (without quotes) in the Terminal.app.
|
||||
#if arch(arm64)
|
||||
@Option(name: [.customLong("rosetta")], help: ArgumentHelp(
|
||||
"Attaches a Rosetta share to the guest Linux VM with a specific tag (e.g. --rosetta=\"rosetta\")",
|
||||
discussion: """
|
||||
Requires host to be macOS 13.0 (Ventura) with Rosetta installed. The latter can be done
|
||||
by running "softwareupdate --install-rosetta" (without quotes) in the Terminal.app.
|
||||
|
||||
Note that you also have to configure Rosetta in the guest Linux VM by following the
|
||||
steps from "Mount the Shared Directory and Register Rosetta" section here:
|
||||
https://developer.apple.com/documentation/virtualization/running_intel_binaries_in_linux_vms_with_rosetta#3978496
|
||||
""",
|
||||
valueName: "tag"
|
||||
))
|
||||
Note that you also have to configure Rosetta in the guest Linux VM by following the
|
||||
steps from "Mount the Shared Directory and Register Rosetta" section here:
|
||||
https://developer.apple.com/documentation/virtualization/running_intel_binaries_in_linux_vms_with_rosetta#3978496
|
||||
""",
|
||||
valueName: "tag"
|
||||
))
|
||||
#endif
|
||||
var rosettaTag: String?
|
||||
|
||||
@Option(help: ArgumentHelp("""
|
||||
Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"~/src/build\" or --dir=\"~/src/sources:ro\")
|
||||
""", discussion: """
|
||||
Requires host to be macOS 13.0 (Ventura) or newer.
|
||||
A shared directory is automatically mounted to "/Volumes/My Shared Files" directory on macOS,
|
||||
while on Linux you have to do it manually: "mount -t virtiofs com.apple.virtio-fs.automount /mount/point".
|
||||
For macOS guests, they must be running macOS 13.0 (Ventura) or newer.
|
||||
@Option(help: ArgumentHelp("Additional directory shares with an optional read-only and mount tag options (e.g. --dir=\"~/src/build\" or --dir=\"~/src/sources:ro\")", discussion: """
|
||||
Requires host to be macOS 13.0 (Ventura) or newer. macOS guests must be running macOS 13.0 (Ventura) or newer too.
|
||||
|
||||
In case of passing multiple directories it is required to prefix them with names e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\"
|
||||
These names will be used as directory names under the mounting point inside guests. For the example above it will be
|
||||
"/Volumes/My Shared Files/build" and "/Volumes/My Shared Files/sources" respectively.
|
||||
""", valueName: "[name:]path[:ro]"))
|
||||
Options are comma-separated and are as follows:
|
||||
|
||||
* ro — mount this directory share in read-only mode instead of the default read-write (e.g. --dir=\"~/src/sources:ro\")
|
||||
|
||||
* tag=<TAG> — by default, the \"com.apple.virtio-fs.automount\" mount tag is used for all directory shares. On macOS, this causes the directories to be automatically mounted to "/Volumes/My Shared Files" directory. On Linux, you have to do it manually: "mount -t virtiofs com.apple.virtio-fs.automount /mount/point".
|
||||
|
||||
Mount tag can be overridden by appending tag property to the directory share (e.g. --dir=\"~/src/build:tag=build\" or --dir=\"~/src/build:ro,tag=build\"). Then it can be mounted via "mount_virtiofs build ~/build" inside guest macOS and "mount -t virtiofs build ~/build" inside guest Linux.
|
||||
|
||||
In case of passing multiple directories per mount tag it is required to prefix them with names e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\". These names will be used as directory names under the mounting point inside guests. For the example above it will be "/Volumes/My Shared Files/build" and "/Volumes/My Shared Files/sources" respectively.
|
||||
""", valueName: "[name:]path[:options]"))
|
||||
var dir: [String] = []
|
||||
|
||||
@Option(help: ArgumentHelp("""
|
||||
@@ -105,9 +117,17 @@ struct Run: AsyncParsableCommand {
|
||||
discussion: "Learn how to configure Softnet for use with Tart here: https://github.com/cirruslabs/softnet"))
|
||||
var netSoftnet: Bool = false
|
||||
|
||||
@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\"."))
|
||||
#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
|
||||
var suspendable: Bool = false
|
||||
|
||||
#if arch(arm64)
|
||||
@Flag(help: ArgumentHelp("Whether system hot keys should be sent to the guest instead of the host",
|
||||
discussion: "If enabled then system hot keys like Cmd+Tab will be sent to the guest instead of the host."))
|
||||
#endif
|
||||
var captureSystemKeys: Bool = false
|
||||
|
||||
mutating func validate() throws {
|
||||
if vnc && vncExperimental {
|
||||
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
|
||||
@@ -117,8 +137,12 @@ struct Run: AsyncParsableCommand {
|
||||
throw ValidationError("--net-bridged and --net-softnet are mutually exclusive")
|
||||
}
|
||||
|
||||
if graphics && noGraphics {
|
||||
throw ValidationError("--graphics and --no-graphics are mutually exclusive")
|
||||
if graphics {
|
||||
print("--graphics is deprecated and will be removed in the future.\n")
|
||||
}
|
||||
|
||||
if (noGraphics || vnc || vncExperimental) && captureSystemKeys {
|
||||
throw ValidationError("--captures-system-keys can only be used with the default VM view")
|
||||
}
|
||||
|
||||
let localStorage = VMStorageLocal()
|
||||
@@ -132,6 +156,12 @@ struct Run: AsyncParsableCommand {
|
||||
throw ValidationError("Suspending VMs with shared directories is not supported")
|
||||
}
|
||||
}
|
||||
|
||||
for disk in disk {
|
||||
if disk.hasSuffix("-amd64.iso") {
|
||||
throw ValidationError("Seems you have a disk targeting x86 architecture (hence amd64 in the name). Please use an 'arm64' version of the disk.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -184,7 +214,8 @@ struct Run: AsyncParsableCommand {
|
||||
additionalStorageDevices: additionalDiskAttachments,
|
||||
directorySharingDevices: directoryShares() + rosettaDirectoryShare(),
|
||||
serialPorts: serialPorts,
|
||||
suspendable: suspendable
|
||||
suspendable: suspendable,
|
||||
audio: !noAudio
|
||||
)
|
||||
|
||||
let vncImpl: VNC? = try {
|
||||
@@ -210,7 +241,7 @@ struct Run: AsyncParsableCommand {
|
||||
// configuration file, otherwise we will loose the lock.
|
||||
//
|
||||
// [1]: https://man.openbsd.org/fcntl
|
||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
||||
let lock = try vmDir.lock()
|
||||
if try !lock.trylock() {
|
||||
throw RuntimeError.VMAlreadyRunning("VM \"\(name)\" is already running!")
|
||||
}
|
||||
@@ -222,15 +253,17 @@ struct Run: AsyncParsableCommand {
|
||||
do {
|
||||
var resume = false
|
||||
|
||||
if #available(macOS 14, *) {
|
||||
if FileManager.default.fileExists(atPath: vmDir.stateURL.path) {
|
||||
print("restoring VM state from a snapshot...")
|
||||
try await vm!.virtualMachine.restoreMachineStateFrom(url: vmDir.stateURL)
|
||||
try FileManager.default.removeItem(at: vmDir.stateURL)
|
||||
resume = true
|
||||
print("resuming VM...")
|
||||
#if arch(arm64)
|
||||
if #available(macOS 14, *) {
|
||||
if FileManager.default.fileExists(atPath: vmDir.stateURL.path) {
|
||||
print("restoring VM state from a snapshot...")
|
||||
try await vm!.virtualMachine.restoreMachineStateFrom(url: vmDir.stateURL)
|
||||
try FileManager.default.removeItem(at: vmDir.stateURL)
|
||||
resume = true
|
||||
print("resuming VM...")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
try await vm!.start(recovery: recovery, resume: resume)
|
||||
|
||||
@@ -276,23 +309,25 @@ struct Run: AsyncParsableCommand {
|
||||
sigusr1Src.setEventHandler {
|
||||
Task {
|
||||
do {
|
||||
if #available(macOS 14, *) {
|
||||
try vm!.configuration.validateSaveRestoreSupport()
|
||||
#if arch(arm64)
|
||||
if #available(macOS 14, *) {
|
||||
try vm!.configuration.validateSaveRestoreSupport()
|
||||
|
||||
print("pausing VM to take a snapshot...")
|
||||
try await vm!.virtualMachine.pause()
|
||||
print("pausing VM to take a snapshot...")
|
||||
try await vm!.virtualMachine.pause()
|
||||
|
||||
print("creating a snapshot...")
|
||||
try await vm!.virtualMachine.saveMachineStateTo(url: vmDir.stateURL)
|
||||
print("creating a snapshot...")
|
||||
try await vm!.virtualMachine.saveMachineStateTo(url: vmDir.stateURL)
|
||||
|
||||
print("snapshot created successfully! shutting down the VM...")
|
||||
print("snapshot created successfully! shutting down the VM...")
|
||||
|
||||
task.cancel()
|
||||
} else {
|
||||
print(RuntimeError.SuspendFailed("this functionality is only supported on macOS 14 (Sonoma) or newer"))
|
||||
task.cancel()
|
||||
} else {
|
||||
print(RuntimeError.SuspendFailed("this functionality is only supported on macOS 14 (Sonoma) or newer"))
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
Foundation.exit(1)
|
||||
}
|
||||
#endif
|
||||
} catch (let e) {
|
||||
print(RuntimeError.SuspendFailed(e.localizedDescription))
|
||||
|
||||
@@ -302,11 +337,14 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
sigusr1Src.activate()
|
||||
|
||||
let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics
|
||||
if noGraphics || useVNCWithoutGraphics {
|
||||
dispatchMain()
|
||||
if noGraphics {
|
||||
// enter the main even loop, without bringing up any UI,
|
||||
// and just wait for the VM to exit.
|
||||
let nsApp = NSApplication.shared
|
||||
nsApp.setActivationPolicy(.prohibited)
|
||||
nsApp.run()
|
||||
} else {
|
||||
runUI(suspendable)
|
||||
runUI(suspendable, captureSystemKeys)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,61 +451,67 @@ struct Run: AsyncParsableCommand {
|
||||
throw UnsupportedOSError("directory sharing", "is")
|
||||
}
|
||||
|
||||
var directoryShares: [DirectoryShare] = []
|
||||
var allDirectoryShares: [DirectoryShare] = []
|
||||
|
||||
var allNamedShares = true
|
||||
for rawDir in dir {
|
||||
let directoryShare = try DirectoryShare(parseFrom: rawDir)
|
||||
if (directoryShare.name == nil) {
|
||||
allNamedShares = false
|
||||
allDirectoryShares.append(try DirectoryShare(parseFrom: rawDir))
|
||||
}
|
||||
|
||||
return try Dictionary(grouping: allDirectoryShares, by: {$0.mountTag}).map { mountTag, directoryShares in
|
||||
let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: mountTag)
|
||||
|
||||
var allNamedShares = true
|
||||
for directoryShare in directoryShares {
|
||||
if directoryShare.name == nil {
|
||||
allNamedShares = false
|
||||
}
|
||||
}
|
||||
directoryShares.append(directoryShare)
|
||||
if directoryShares.count == 1 {
|
||||
let directoryShare = directoryShares.first!
|
||||
let singleDirectoryShare = VZSingleDirectoryShare(directory: try directoryShare.createConfiguration())
|
||||
sharingDevice.share = singleDirectoryShare
|
||||
} else if !allNamedShares {
|
||||
throw ValidationError("invalid --dir syntax: for multiple directory shares each one of them should be named")
|
||||
} else {
|
||||
var directories: [String : VZSharedDirectory] = Dictionary()
|
||||
try directoryShares.forEach { directories[$0.name!] = try $0.createConfiguration() }
|
||||
sharingDevice.share = VZMultipleDirectoryShare(directories: directories)
|
||||
}
|
||||
|
||||
return sharingDevice
|
||||
}
|
||||
|
||||
|
||||
let automountTag = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
|
||||
let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: automountTag)
|
||||
if allNamedShares {
|
||||
var directories: [String : VZSharedDirectory] = Dictionary()
|
||||
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: try directoryShare.createConfiguration())
|
||||
sharingDevice.share = singleDirectoryShare
|
||||
}
|
||||
|
||||
return [sharingDevice]
|
||||
}
|
||||
|
||||
private func rosettaDirectoryShare() throws -> [VZDirectorySharingDeviceConfiguration] {
|
||||
guard let rosettaTag = rosettaTag else {
|
||||
return []
|
||||
}
|
||||
#if arch(arm64)
|
||||
guard #available(macOS 13, *) else {
|
||||
throw UnsupportedOSError("Rosetta directory share", "is")
|
||||
}
|
||||
|
||||
guard #available(macOS 13, *) else {
|
||||
throw UnsupportedOSError("Rosetta directory share", "is")
|
||||
}
|
||||
switch VZLinuxRosettaDirectoryShare.availability {
|
||||
case .notInstalled:
|
||||
throw UnsupportedOSError("Rosetta directory share", "is", "that have Rosetta installed")
|
||||
case .notSupported:
|
||||
throw UnsupportedOSError("Rosetta directory share", "is", "running Apple silicon")
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
switch VZLinuxRosettaDirectoryShare.availability {
|
||||
case .notInstalled:
|
||||
throw UnsupportedOSError("Rosetta directory share", "is", "that have Rosetta installed")
|
||||
case .notSupported:
|
||||
throw UnsupportedOSError("Rosetta directory share", "is", "running Apple silicon")
|
||||
default:
|
||||
break
|
||||
}
|
||||
try VZVirtioFileSystemDeviceConfiguration.validateTag(rosettaTag)
|
||||
let device = VZVirtioFileSystemDeviceConfiguration(tag: rosettaTag)
|
||||
device.share = try VZLinuxRosettaDirectoryShare()
|
||||
|
||||
try VZVirtioFileSystemDeviceConfiguration.validateTag(rosettaTag)
|
||||
let device = VZVirtioFileSystemDeviceConfiguration(tag: rosettaTag)
|
||||
device.share = try VZLinuxRosettaDirectoryShare()
|
||||
|
||||
return [device]
|
||||
return [device]
|
||||
#elseif arch(x86_64)
|
||||
// there is no Rosetta on Intel
|
||||
return []
|
||||
#endif
|
||||
}
|
||||
|
||||
private func runUI(_ suspendable: Bool) {
|
||||
private func runUI(_ suspendable: Bool, _ captureSystemKeys: Bool) {
|
||||
let nsApp = NSApplication.shared
|
||||
nsApp.setActivationPolicy(.regular)
|
||||
nsApp.activate(ignoringOtherApps: true)
|
||||
@@ -476,13 +520,14 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
struct MainApp: App {
|
||||
static var disappearSignal: Int32 = SIGINT
|
||||
static var capturesSystemKeys: Bool = false
|
||||
|
||||
@NSApplicationDelegateAdaptor private var appDelegate: MinimalMenuAppDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup(vm!.name) {
|
||||
Group {
|
||||
VMView(vm: vm!).onAppear {
|
||||
VMView(vm: vm!, capturesSystemKeys: MainApp.capturesSystemKeys).onAppear {
|
||||
NSWindow.allowsAutomaticWindowTabbing = false
|
||||
}.onDisappear {
|
||||
let ret = kill(getpid(), MainApp.disappearSignal)
|
||||
@@ -532,6 +577,7 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
MainApp.disappearSignal = suspendable ? SIGUSR1 : SIGINT
|
||||
MainApp.capturesSystemKeys = captureSystemKeys
|
||||
MainApp.main()
|
||||
}
|
||||
}
|
||||
@@ -581,18 +627,19 @@ struct VMView: NSViewRepresentable {
|
||||
typealias NSViewType = VZVirtualMachineView
|
||||
|
||||
@ObservedObject var vm: VM
|
||||
var capturesSystemKeys: Bool
|
||||
|
||||
func makeNSView(context: Context) -> NSViewType {
|
||||
let machineView = VZVirtualMachineView()
|
||||
|
||||
// Do not capture system keys so that shortcuts like
|
||||
// Shift-Command-4 + Space (capture a screenshot of window)
|
||||
// work on the host instead of the guest
|
||||
machineView.capturesSystemKeys = false
|
||||
machineView.capturesSystemKeys = capturesSystemKeys
|
||||
|
||||
// Enable automatic display reconfiguration
|
||||
// for guests that support it
|
||||
if #available(macOS 14.0, *) {
|
||||
//
|
||||
// This is disabled for Linux because of poor HiDPI
|
||||
// support, which manifests in fonts being too small
|
||||
if #available(macOS 14.0, *), vm.config.os != .linux {
|
||||
machineView.automaticallyReconfiguresDisplay = true
|
||||
}
|
||||
|
||||
@@ -608,30 +655,62 @@ struct DirectoryShare {
|
||||
let name: String?
|
||||
let path: URL
|
||||
let readOnly: Bool
|
||||
let mountTag: String
|
||||
|
||||
init(parseFrom: String) throws {
|
||||
let readOnlySuffix = ":ro"
|
||||
readOnly = parseFrom.hasSuffix(readOnlySuffix)
|
||||
let maybeNameAndURL = readOnly ? String(parseFrom.dropLast(readOnlySuffix.count)) : parseFrom
|
||||
var parseFrom = parseFrom
|
||||
|
||||
// Consume options
|
||||
(self.readOnly, self.mountTag, parseFrom) = Self.parseOptions(parseFrom)
|
||||
|
||||
// Special case for URLs
|
||||
if parseFrom.hasPrefix("http:") || parseFrom.hasPrefix("https:") {
|
||||
self.name = nil
|
||||
self.path = URL(string: parseFrom)!
|
||||
|
||||
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)
|
||||
let arguments = parseFrom.split(separator: ":", maxSplits: 1)
|
||||
|
||||
if splits.count == 2 {
|
||||
name = String(splits[0])
|
||||
path = String(splits[1]).toRemoteOrLocalURL()
|
||||
if arguments.count == 2 {
|
||||
self.name = String(arguments[0])
|
||||
self.path = String(arguments[1]).toRemoteOrLocalURL()
|
||||
} else {
|
||||
name = nil
|
||||
path = String(splits[0]).toRemoteOrLocalURL()
|
||||
self.name = nil
|
||||
self.path = String(arguments[0]).toRemoteOrLocalURL()
|
||||
}
|
||||
}
|
||||
|
||||
static func parseOptions(_ parseFrom: String) -> (Bool, String, String) {
|
||||
var arguments = parseFrom.split(separator: ":")
|
||||
let options = arguments.last!.split(separator: ",")
|
||||
|
||||
var readOnly: Bool = false
|
||||
var mountTag: String = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
|
||||
|
||||
var found: Bool = false
|
||||
|
||||
for option in options {
|
||||
switch true {
|
||||
case option == "ro":
|
||||
readOnly = true
|
||||
found = true
|
||||
case option.hasPrefix("tag="):
|
||||
mountTag = String(option.dropFirst(4))
|
||||
found = true
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if found {
|
||||
arguments.removeLast()
|
||||
}
|
||||
|
||||
return (readOnly, mountTag, arguments.joined(separator: ":"))
|
||||
}
|
||||
|
||||
func createConfiguration() throws -> VZSharedDirectory {
|
||||
if (path.isFileURL) {
|
||||
return VZSharedDirectory(url: path, readOnly: readOnly)
|
||||
@@ -641,7 +720,7 @@ struct DirectoryShare {
|
||||
|
||||
let archiveRequest = URLRequest(url: path, cachePolicy: .returnCacheDataElseLoad)
|
||||
var response: CachedURLResponse? = urlCache.cachedResponse(for: archiveRequest)
|
||||
if (response == nil) {
|
||||
if (response == nil || response?.data.isEmpty == true) {
|
||||
print("Downloading \(path)...")
|
||||
// download and unarchive remote directories if needed here
|
||||
// use old school API to prevent deadlocks since we are running via MainActor
|
||||
@@ -649,8 +728,12 @@ struct DirectoryShare {
|
||||
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!")
|
||||
if archiveData.isEmpty {
|
||||
print("Remote archive is empty!")
|
||||
} else {
|
||||
urlCache.storeCachedResponse(CachedURLResponse(response: archiveResponse, data: archiveData, storagePolicy: .allowed), for: archiveRequest)
|
||||
print("Cached for future invocations!")
|
||||
}
|
||||
} catch {
|
||||
print("Download failed: \(error)")
|
||||
}
|
||||
|
||||
@@ -16,7 +16,18 @@ struct Set: AsyncParsableCommand {
|
||||
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
|
||||
var display: VMDisplayConfig?
|
||||
|
||||
@Option(help: .hidden)
|
||||
@Option(help: ArgumentHelp("Resize the VMs disk to the specified size in GB (note that the disk size can only be increased to avoid losing data)",
|
||||
discussion: """
|
||||
Disk resizing works on most cloud-ready Linux distributions out-of-the box (e.g. Ubuntu Cloud Images
|
||||
have the \"cloud-initramfs-growroot\" package installed that runs on boot) and on the rest of the
|
||||
distributions by running the \"growpart\" or \"resize2fs\" commands.
|
||||
|
||||
For macOS, however, things are a bit more complicated: you need to remove the recovery partition
|
||||
first and then run various \"diskutil\" commands, see Tart's packer plugin source code for more
|
||||
details[1].
|
||||
|
||||
[1]: https://github.com/cirruslabs/packer-plugin-tart/blob/main/builder/tart/step_disk_resize.go
|
||||
"""))
|
||||
var diskSize: UInt16?
|
||||
|
||||
func run() async throws {
|
||||
|
||||
@@ -29,12 +29,12 @@ struct Stop: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
func stopRunning(_ vmDir: VMDirectory) async throws {
|
||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
||||
let lock = try vmDir.lock()
|
||||
|
||||
// Find the VM's PID
|
||||
var pid = try lock.pid()
|
||||
if pid == 0 {
|
||||
throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
|
||||
throw RuntimeError.VMNotRunning(name)
|
||||
}
|
||||
|
||||
// Try to gracefully terminate the VM
|
||||
|
||||
@@ -11,7 +11,7 @@ struct Suspend: AsyncParsableCommand {
|
||||
|
||||
func run() async throws {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
||||
let lock = try vmDir.lock()
|
||||
|
||||
// Find the VM's PID
|
||||
var pid = try lock.pid()
|
||||
|
||||
@@ -41,13 +41,18 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
|
||||
|
||||
process.waitUntilExit()
|
||||
|
||||
let outputData = try outPipe.fileHandleForReading.readToEnd()
|
||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||
if let outputData = outputData {
|
||||
print(String(decoding: outputData, as: UTF8.self))
|
||||
}
|
||||
throw CredentialsProviderError.Failed(message: "Docker helper failed!")
|
||||
}
|
||||
if outputData == nil || outputData?.count == 0 {
|
||||
throw CredentialsProviderError.Failed(message: "Docker helper output is empty!")
|
||||
}
|
||||
|
||||
let getOutput = try JSONDecoder().decode(
|
||||
DockerGetOutput.self, from: outPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
)
|
||||
let getOutput = try JSONDecoder().decode(DockerGetOutput.self, from: outputData!)
|
||||
return (getOutput.Username, getOutput.Secret)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import SwiftRadix
|
||||
|
||||
struct Lease {
|
||||
var mac: MACAddress
|
||||
var ip: IPv4Address
|
||||
var expiresAt: Date
|
||||
|
||||
init?(fromRawLease: [String : String]) {
|
||||
// Retrieve the required fields
|
||||
guard let hwAddress = fromRawLease["hw_address"] else { return nil }
|
||||
guard let ipAddress = fromRawLease["ip_address"] else { return nil }
|
||||
guard let lease = fromRawLease["lease"] else { return nil }
|
||||
|
||||
// Parse MAC address
|
||||
let hwAddressSplits = hwAddress.split(separator: ",")
|
||||
@@ -26,7 +30,13 @@ struct Lease {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse expiration timestamp
|
||||
guard let leaseTimestamp = lease.hex?.value else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.ip = ip
|
||||
self.mac = mac
|
||||
self.expiresAt = Date(timeIntervalSince1970: TimeInterval(leaseTimestamp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,13 +37,18 @@ class Leases {
|
||||
}
|
||||
|
||||
init(_ fromString: String) throws {
|
||||
var leases: [MACAddress : Lease] = Dictionary()
|
||||
let leases = try Self.retrieveRawLeases(fromString).compactMap({ rawLease in
|
||||
Lease(fromRawLease: rawLease)
|
||||
}).filter({ lease in
|
||||
lease.expiresAt.isInFuture
|
||||
}).map({ lease in
|
||||
(lease.mac, lease)
|
||||
})
|
||||
|
||||
for lease in try Self.retrieveRawLeases(fromString).compactMap({ Lease(fromRawLease: $0) }) {
|
||||
leases[lease.mac] = lease
|
||||
self.leases = Dictionary(leases) { (left, right) in
|
||||
// When duplicate lease is found, prefer a newer lease over the older one
|
||||
(left.expiresAt > right.expiresAt) ? left : right
|
||||
}
|
||||
|
||||
self.leases = leases
|
||||
}
|
||||
|
||||
/// Parse leases from the host cache similarly to the PLCache_read() function found in Apple's Open Source releases.
|
||||
@@ -107,7 +112,7 @@ class Leases {
|
||||
return rawLeases
|
||||
}
|
||||
|
||||
func ResolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
|
||||
func ResolveMACAddress(macAddress: MACAddress) -> IPv4Address? {
|
||||
leases[macAddress]?.ip
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
|
||||
actor AuthenticationKeeper {
|
||||
var authentication: Authentication? = nil
|
||||
|
||||
func set(_ authentication: Authentication) {
|
||||
self.authentication = authentication
|
||||
}
|
||||
|
||||
func header() -> (String, String)? {
|
||||
if let authentication = authentication {
|
||||
// Do not suggest any headers if the
|
||||
// authentication token has expired
|
||||
if !authentication.isValid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return authentication.header()
|
||||
}
|
||||
|
||||
// Do not suggest any headers if the
|
||||
// authentication token is not set
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,22 @@ import Compression
|
||||
class DiskV2: Disk {
|
||||
private static let bufferSizeBytes = 4 * 1024 * 1024
|
||||
private static let layerLimitBytes = 500 * 1000 * 1000
|
||||
private static let holeGranularityBytes = 64 * 1024
|
||||
|
||||
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)
|
||||
var mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||
|
||||
// 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) {
|
||||
var offset: UInt64 = 0
|
||||
|
||||
while let (compressedData, uncompressedSize, uncompressedDigest) = try compressNextLayerOfLimitBytesOrMore(mappedDisk: mappedDisk, offset: offset) {
|
||||
offset += uncompressedSize
|
||||
|
||||
let layerDigest = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb)
|
||||
|
||||
pushedLayers.append(OCIManifestLayer(
|
||||
@@ -88,14 +93,38 @@ class DiskV2: Disk {
|
||||
return
|
||||
}
|
||||
|
||||
// Open the disk file at the specific offset
|
||||
// Open the disk file
|
||||
let disk = try FileHandle(forWritingTo: diskURL)
|
||||
try disk.seek(toOffset: diskWritingOffset)
|
||||
|
||||
// A zero chunk for faster than byte-by-byte comparisons
|
||||
//
|
||||
// Assumes that the other Data(...) is equal in size, but it's fine to get a false-negative
|
||||
// on the last block since it costs only 64 KiB of excess data per 500 MB layer.
|
||||
//
|
||||
// Some simple benchmarks ("sync && sudo purge" command was used to negate the disk caching effects):
|
||||
// +--------------------------------------+---------------------------------------------------+
|
||||
// | Operation | time(1) result |
|
||||
// +--------------------------------------+---------------------------------------------------+
|
||||
// | Data(...) == zeroChunk | 2.16s user 11.71s system 73% cpu 18.928 total |
|
||||
// | Data(...).contains(where: {$0 != 0}) | 603.68s user 12.97s system 99% cpu 10:22.85 total |
|
||||
// +--------------------------------------+---------------------------------------------------+
|
||||
let zeroChunk = Data(count: holeGranularityBytes)
|
||||
var diskWritingOffset = 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)
|
||||
guard let data = data else {
|
||||
return
|
||||
}
|
||||
|
||||
for chunk in data.chunks(ofCount: holeGranularityBytes) {
|
||||
// Only write chunks that are not zero
|
||||
if chunk != zeroChunk {
|
||||
try disk.seek(toOffset: diskWritingOffset)
|
||||
disk.write(chunk)
|
||||
}
|
||||
|
||||
diskWritingOffset += UInt64(chunk.count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +145,7 @@ class DiskV2: Disk {
|
||||
}
|
||||
}
|
||||
|
||||
private static func compressNextLayerOfLimitBytesOrMore(disk: FileHandle) throws -> (Data, UInt64, String)? {
|
||||
private static func compressNextLayerOfLimitBytesOrMore(mappedDisk: Data, offset: UInt64) throws -> (Data, UInt64, String)? {
|
||||
var compressedData = Data()
|
||||
var bytesRead: UInt64 = 0
|
||||
let digest = Digest()
|
||||
@@ -128,10 +157,15 @@ class DiskV2: Disk {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let uncompressedChunk = try disk.read(upToCount: bufferSizeBytes) else {
|
||||
let readFromByte = Int(offset + bytesRead)
|
||||
|
||||
let numBytesToRead = min(mappedDisk.count - readFromByte, bufferSizeBytes)
|
||||
if numBytesToRead == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
let uncompressedChunk = mappedDisk.subdata(in: readFromByte ..< (readFromByte + numBytesToRead))
|
||||
|
||||
bytesRead += UInt64(uncompressedChunk.count)
|
||||
digest.update(uncompressedChunk)
|
||||
|
||||
|
||||
@@ -102,8 +102,7 @@ class Registry {
|
||||
private let baseURL: URL
|
||||
let namespace: String
|
||||
let credentialsProviders: [CredentialsProvider]
|
||||
|
||||
var currentAuthToken: Authentication? = nil
|
||||
let authenticationKeeper = AuthenticationKeeper()
|
||||
|
||||
var host: String? {
|
||||
guard let host = baseURL.host else { return nil }
|
||||
@@ -305,11 +304,6 @@ class Registry {
|
||||
request.httpBody = body
|
||||
}
|
||||
|
||||
// Invalidate token if it has expired
|
||||
if currentAuthToken?.isValid() == false {
|
||||
currentAuthToken = nil
|
||||
}
|
||||
|
||||
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
|
||||
|
||||
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
|
||||
@@ -331,7 +325,7 @@ class Registry {
|
||||
|
||||
if wwwAuthenticate.scheme.lowercased() == "basic" {
|
||||
if let (user, password) = try lookupCredentials() {
|
||||
currentAuthToken = BasicAuthentication(user: user, password: password)
|
||||
await authenticationKeeper.set(BasicAuthentication(user: user, password: password))
|
||||
}
|
||||
|
||||
return
|
||||
@@ -378,7 +372,7 @@ class Registry {
|
||||
+ "while retrieving an authentication token", details: data.asText())
|
||||
}
|
||||
|
||||
currentAuthToken = try TokenResponse.parse(fromData: data)
|
||||
await authenticationKeeper.set(try TokenResponse.parse(fromData: data))
|
||||
}
|
||||
|
||||
private func lookupCredentials() throws -> (String, String)? {
|
||||
@@ -399,8 +393,7 @@ class Registry {
|
||||
private func authAwareRequest(request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
var request = request
|
||||
|
||||
if let token = currentAuthToken {
|
||||
let (name, value) = token.header()
|
||||
if let (name, value) = await authenticationKeeper.header() {
|
||||
request.addValue(value, forHTTPHeaderField: name)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,127 +6,131 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
|
||||
}
|
||||
}
|
||||
|
||||
struct Darwin: PlatformSuspendable {
|
||||
var ecid: VZMacMachineIdentifier
|
||||
var hardwareModel: VZMacHardwareModel
|
||||
#if arch(arm64)
|
||||
|
||||
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
|
||||
self.ecid = ecid
|
||||
self.hardwareModel = hardwareModel
|
||||
}
|
||||
struct Darwin: PlatformSuspendable {
|
||||
var ecid: VZMacMachineIdentifier
|
||||
var hardwareModel: VZMacHardwareModel
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
let encodedECID = try container.decode(String.self, forKey: .ecid)
|
||||
guard let data = Data.init(base64Encoded: encodedECID) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||
in: container,
|
||||
debugDescription: "failed to initialize Data using the provided value")
|
||||
}
|
||||
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||
in: container,
|
||||
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
|
||||
}
|
||||
self.ecid = ecid
|
||||
|
||||
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
|
||||
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||
}
|
||||
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||
}
|
||||
self.hardwareModel = hardwareModel
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
|
||||
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
|
||||
}
|
||||
|
||||
func os() -> OS {
|
||||
.darwin
|
||||
}
|
||||
|
||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
||||
VZMacOSBootLoader()
|
||||
}
|
||||
|
||||
func platform(nvramURL: URL) throws -> VZPlatformConfiguration {
|
||||
let result = VZMacPlatformConfiguration()
|
||||
|
||||
result.machineIdentifier = ecid
|
||||
result.auxiliaryStorage = VZMacAuxiliaryStorage(url: nvramURL)
|
||||
|
||||
if !hardwareModel.isSupported {
|
||||
// At the moment support of M1 chip is not yet dropped in any macOS version
|
||||
// This mean that host software is not supporting this hardware model and should be updated
|
||||
throw UnsupportedHostOSError()
|
||||
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
|
||||
self.ecid = ecid
|
||||
self.hardwareModel = hardwareModel
|
||||
}
|
||||
|
||||
result.hardwareModel = hardwareModel
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
return result
|
||||
}
|
||||
let encodedECID = try container.decode(String.self, forKey: .ecid)
|
||||
guard let data = Data.init(base64Encoded: encodedECID) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||
in: container,
|
||||
debugDescription: "failed to initialize Data using the provided value")
|
||||
}
|
||||
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||
in: container,
|
||||
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
|
||||
}
|
||||
self.ecid = ecid
|
||||
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||
let result = VZMacGraphicsDeviceConfiguration()
|
||||
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
|
||||
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||
}
|
||||
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||
}
|
||||
self.hardwareModel = hardwareModel
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
|
||||
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
|
||||
}
|
||||
|
||||
func os() -> OS {
|
||||
.darwin
|
||||
}
|
||||
|
||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
||||
VZMacOSBootLoader()
|
||||
}
|
||||
|
||||
func platform(nvramURL: URL) throws -> VZPlatformConfiguration {
|
||||
let result = VZMacPlatformConfiguration()
|
||||
|
||||
result.machineIdentifier = ecid
|
||||
result.auxiliaryStorage = VZMacAuxiliaryStorage(url: nvramURL)
|
||||
|
||||
if !hardwareModel.isSupported {
|
||||
// At the moment support of M1 chip is not yet dropped in any macOS version
|
||||
// This mean that host software is not supporting this hardware model and should be updated
|
||||
throw UnsupportedHostOSError()
|
||||
}
|
||||
|
||||
result.hardwareModel = hardwareModel
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||
let result = VZMacGraphicsDeviceConfiguration()
|
||||
|
||||
if let hostMainScreen = NSScreen.main {
|
||||
let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
|
||||
result.displays = [
|
||||
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
|
||||
]
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
if let hostMainScreen = NSScreen.main {
|
||||
let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
|
||||
result.displays = [
|
||||
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
|
||||
VZMacGraphicsDisplayConfiguration(
|
||||
widthInPixels: vmConfig.display.width,
|
||||
heightInPixels: vmConfig.display.height,
|
||||
// A reasonable guess according to Apple's documentation[1]
|
||||
// [1]: https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
|
||||
pixelsPerInch: 72
|
||||
)
|
||||
]
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
result.displays = [
|
||||
VZMacGraphicsDisplayConfiguration(
|
||||
widthInPixels: vmConfig.display.width,
|
||||
heightInPixels: vmConfig.display.height,
|
||||
// A reasonable guess according to Apple's documentation[1]
|
||||
// [1]: https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
|
||||
pixelsPerInch: 72
|
||||
)
|
||||
]
|
||||
func keyboards() -> [VZKeyboardConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
// Mac keyboard is only supported by guests starting with macOS Ventura
|
||||
return [VZMacKeyboardConfiguration(), VZUSBKeyboardConfiguration()]
|
||||
} else {
|
||||
return [VZUSBKeyboardConfiguration()]
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
func keyboardsSuspendable() -> [VZKeyboardConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
return [VZMacKeyboardConfiguration()]
|
||||
} else {
|
||||
// fallback to the regular configuration
|
||||
return keyboards()
|
||||
}
|
||||
}
|
||||
|
||||
func keyboards() -> [VZKeyboardConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
// Mac keyboard is only supported by guests starting with macOS Ventura
|
||||
return [VZMacKeyboardConfiguration(), VZUSBKeyboardConfiguration()]
|
||||
} else {
|
||||
return [VZUSBKeyboardConfiguration()]
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||
// Trackpad is only supported by guests starting with macOS Ventura
|
||||
[VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
|
||||
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
return [VZMacTrackpadConfiguration()]
|
||||
} else {
|
||||
// fallback to the regular configuration
|
||||
return pointingDevices()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func keyboardsSuspendable() -> [VZKeyboardConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
return [VZMacKeyboardConfiguration()]
|
||||
} else {
|
||||
// fallback to the regular configuration
|
||||
return keyboards()
|
||||
}
|
||||
}
|
||||
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||
// Trackpad is only supported by guests starting with macOS Ventura
|
||||
[VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
|
||||
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
return [VZMacTrackpadConfiguration()]
|
||||
} else {
|
||||
// fallback to the regular configuration
|
||||
return pointingDevices()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -8,5 +8,8 @@ protocol Prunable {
|
||||
var url: URL { get }
|
||||
func delete() throws
|
||||
func accessDate() throws -> Date
|
||||
// size on disk as seen in Finder including empty blocks
|
||||
func sizeBytes() throws -> Int
|
||||
// actual size on disk without empty blocks
|
||||
func allocatedSizeBytes() throws -> Int
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ struct Root: AsyncParsableCommand {
|
||||
Rename.self,
|
||||
Stop.self,
|
||||
Delete.self,
|
||||
FQN.self,
|
||||
])
|
||||
|
||||
public static func main() async throws {
|
||||
|
||||
@@ -9,7 +9,11 @@ extension URL: Prunable {
|
||||
try FileManager.default.removeItem(at: self)
|
||||
}
|
||||
|
||||
func sizeBytes() throws -> Int {
|
||||
func allocatedSizeBytes() throws -> Int {
|
||||
try resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize!
|
||||
}
|
||||
|
||||
func sizeBytes() throws -> Int {
|
||||
try resourceValues(forKeys: [.totalFileSizeKey]).totalFileSize!
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||
serialPorts: [VZSerialPortConfiguration] = [],
|
||||
suspendable: Bool = false
|
||||
suspendable: Bool = false,
|
||||
audio: Bool = true
|
||||
) throws {
|
||||
name = vmDir.name
|
||||
config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
@@ -61,7 +62,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
network: network, additionalStorageDevices: additionalStorageDevices,
|
||||
directorySharingDevices: directorySharingDevices,
|
||||
serialPorts: serialPorts,
|
||||
suspendable: suspendable
|
||||
suspendable: suspendable,
|
||||
audio: audio
|
||||
)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
@@ -116,18 +118,6 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
return try FileManager.default.replaceItemAt(finalLocation, withItemAt: temporaryLocation)!
|
||||
}
|
||||
|
||||
static func latestIPSWURL() async throws -> URL {
|
||||
defaultLogger.appendNewLine("Looking up the latest supported IPSW...")
|
||||
|
||||
let image = try await withCheckedThrowingContinuation { continuation in
|
||||
VZMacOSRestoreImage.fetchLatestSupported() { result in
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
}
|
||||
|
||||
return image.url
|
||||
}
|
||||
|
||||
var inFinalState: Bool {
|
||||
get {
|
||||
virtualMachine.state == VZVirtualMachine.State.stopped ||
|
||||
@@ -137,82 +127,84 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
vmDir: VMDirectory,
|
||||
ipswURL: URL,
|
||||
diskSizeGB: UInt16,
|
||||
network: Network = NetworkShared(),
|
||||
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||
serialPorts: [VZSerialPortConfiguration] = []
|
||||
) async throws {
|
||||
var ipswURL = ipswURL
|
||||
#if arch(arm64)
|
||||
init(
|
||||
vmDir: VMDirectory,
|
||||
ipswURL: URL,
|
||||
diskSizeGB: UInt16,
|
||||
network: Network = NetworkShared(),
|
||||
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||
serialPorts: [VZSerialPortConfiguration] = []
|
||||
) async throws {
|
||||
var ipswURL = ipswURL
|
||||
|
||||
if !ipswURL.isFileURL {
|
||||
ipswURL = try await VM.retrieveIPSW(remoteURL: ipswURL)
|
||||
}
|
||||
|
||||
// We create a temporary TART_HOME directory in tests, which has its "cache" folder symlinked
|
||||
// to the users Tart cache directory (~/.tart/cache). However, the Virtualization.Framework
|
||||
// cannot deal with paths that contain symlinks, so expand them here first.
|
||||
ipswURL.resolveSymlinksInPath()
|
||||
|
||||
// Load the restore image and try to get the requirements
|
||||
// that match both the image and our platform
|
||||
let image = try await withCheckedThrowingContinuation { continuation in
|
||||
VZMacOSRestoreImage.load(from: ipswURL) { result in
|
||||
continuation.resume(with: result)
|
||||
if !ipswURL.isFileURL {
|
||||
ipswURL = try await VM.retrieveIPSW(remoteURL: ipswURL)
|
||||
}
|
||||
}
|
||||
|
||||
guard let requirements = image.mostFeaturefulSupportedConfiguration else {
|
||||
throw UnsupportedRestoreImageError()
|
||||
}
|
||||
// We create a temporary TART_HOME directory in tests, which has its "cache" folder symlinked
|
||||
// to the users Tart cache directory (~/.tart/cache). However, the Virtualization.Framework
|
||||
// cannot deal with paths that contain symlinks, so expand them here first.
|
||||
ipswURL.resolveSymlinksInPath()
|
||||
|
||||
// Create NVRAM
|
||||
_ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
|
||||
|
||||
// Create disk
|
||||
try vmDir.resizeDisk(diskSizeGB)
|
||||
|
||||
name = vmDir.name
|
||||
// Create config
|
||||
config = VMConfig(
|
||||
platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
|
||||
cpuCountMin: requirements.minimumSupportedCPUCount,
|
||||
memorySizeMin: requirements.minimumSupportedMemorySize
|
||||
)
|
||||
// allocate at least 4 CPUs because otherwise VMs are frequently freezing
|
||||
try config.setCPU(cpuCount: max(4, requirements.minimumSupportedCPUCount))
|
||||
try config.save(toURL: vmDir.configURL)
|
||||
|
||||
// Initialize the virtual machine and its configuration
|
||||
self.network = network
|
||||
configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
||||
vmConfig: config, network: network,
|
||||
additionalStorageDevices: additionalStorageDevices,
|
||||
directorySharingDevices: directorySharingDevices,
|
||||
serialPorts: serialPorts
|
||||
)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
super.init()
|
||||
virtualMachine.delegate = self
|
||||
|
||||
// Run automated installation
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
DispatchQueue.main.async { [ipswURL] in
|
||||
let installer = VZMacOSInstaller(virtualMachine: self.virtualMachine, restoringFromImageAt: ipswURL)
|
||||
|
||||
defaultLogger.appendNewLine("Installing OS...")
|
||||
ProgressObserver(installer.progress).log(defaultLogger)
|
||||
|
||||
installer.install { result in
|
||||
// Load the restore image and try to get the requirements
|
||||
// that match both the image and our platform
|
||||
let image = try await withCheckedThrowingContinuation { continuation in
|
||||
VZMacOSRestoreImage.load(from: ipswURL) { result in
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
}
|
||||
|
||||
guard let requirements = image.mostFeaturefulSupportedConfiguration else {
|
||||
throw UnsupportedRestoreImageError()
|
||||
}
|
||||
|
||||
// Create NVRAM
|
||||
_ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
|
||||
|
||||
// Create disk
|
||||
try vmDir.resizeDisk(diskSizeGB)
|
||||
|
||||
name = vmDir.name
|
||||
// Create config
|
||||
config = VMConfig(
|
||||
platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
|
||||
cpuCountMin: requirements.minimumSupportedCPUCount,
|
||||
memorySizeMin: requirements.minimumSupportedMemorySize
|
||||
)
|
||||
// allocate at least 4 CPUs because otherwise VMs are frequently freezing
|
||||
try config.setCPU(cpuCount: max(4, requirements.minimumSupportedCPUCount))
|
||||
try config.save(toURL: vmDir.configURL)
|
||||
|
||||
// Initialize the virtual machine and its configuration
|
||||
self.network = network
|
||||
configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
||||
vmConfig: config, network: network,
|
||||
additionalStorageDevices: additionalStorageDevices,
|
||||
directorySharingDevices: directorySharingDevices,
|
||||
serialPorts: serialPorts
|
||||
)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
super.init()
|
||||
virtualMachine.delegate = self
|
||||
|
||||
// Run automated installation
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
DispatchQueue.main.async { [ipswURL] in
|
||||
let installer = VZMacOSInstaller(virtualMachine: self.virtualMachine, restoringFromImageAt: ipswURL)
|
||||
|
||||
defaultLogger.appendNewLine("Installing OS...")
|
||||
ProgressObserver(installer.progress).log(defaultLogger)
|
||||
|
||||
installer.install { result in
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@available(macOS 13, *)
|
||||
static func linux(vmDir: VMDirectory, diskSizeGB: UInt16) async throws -> VM {
|
||||
@@ -257,9 +249,13 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
|
||||
@MainActor
|
||||
private func start(_ recovery: Bool) async throws {
|
||||
let startOptions = VZMacOSVirtualMachineStartOptions()
|
||||
startOptions.startUpFromMacOSRecovery = recovery
|
||||
try await virtualMachine.start(options: startOptions)
|
||||
#if arch(arm64)
|
||||
let startOptions = VZMacOSVirtualMachineStartOptions()
|
||||
startOptions.startUpFromMacOSRecovery = recovery
|
||||
try await virtualMachine.start(options: startOptions)
|
||||
#else
|
||||
try await virtualMachine.start()
|
||||
#endif
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -280,7 +276,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
additionalStorageDevices: [VZStorageDeviceConfiguration],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration],
|
||||
serialPorts: [VZSerialPortConfiguration],
|
||||
suspendable: Bool = false
|
||||
suspendable: Bool = false,
|
||||
audio: Bool = true
|
||||
) throws -> VZVirtualMachineConfiguration {
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
|
||||
@@ -298,7 +295,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
|
||||
|
||||
// Audio
|
||||
if !suspendable {
|
||||
if audio && !suspendable {
|
||||
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
||||
let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration()
|
||||
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
|
||||
@@ -326,9 +323,18 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
// Storage
|
||||
var devices: [VZStorageDeviceConfiguration] = [
|
||||
VZVirtioBlockDeviceConfiguration(attachment: try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false))
|
||||
]
|
||||
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)
|
||||
|
||||
var device: VZStorageDeviceConfiguration
|
||||
if #available(macOS 14, *), vmConfig.os == .linux {
|
||||
device = VZNVMExpressControllerDeviceConfiguration(attachment: attachment)
|
||||
} else {
|
||||
device = VZVirtioBlockDeviceConfiguration(attachment: attachment)
|
||||
}
|
||||
var devices: [VZStorageDeviceConfiguration] = [device]
|
||||
devices.append(contentsOf: additionalStorageDevices)
|
||||
configuration.storageDevices = devices
|
||||
|
||||
|
||||
@@ -95,7 +95,14 @@ struct VMConfig: Codable {
|
||||
arch = try container.decodeIfPresent(Architecture.self, forKey: .arch) ?? .arm64
|
||||
switch os {
|
||||
case .darwin:
|
||||
platform = try Darwin(from: decoder)
|
||||
#if arch(arm64)
|
||||
platform = try Darwin(from: decoder)
|
||||
#else
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .os,
|
||||
in: container,
|
||||
debugDescription: "Darwin VMs are only supported on Apple Silicon hosts")
|
||||
#endif
|
||||
case .linux:
|
||||
platform = try Linux(from: decoder)
|
||||
}
|
||||
@@ -132,20 +139,30 @@ struct VMConfig: Codable {
|
||||
}
|
||||
|
||||
mutating func setCPU(cpuCount: Int) throws {
|
||||
if cpuCount < cpuCountMin {
|
||||
if os == .darwin && cpuCount < cpuCountMin {
|
||||
throw LessThanMinimalResourcesError("VM should have \(cpuCountMin) CPU cores"
|
||||
+ " at minimum (requested \(cpuCount))")
|
||||
}
|
||||
|
||||
if cpuCount < VZVirtualMachineConfiguration.minimumAllowedCPUCount {
|
||||
throw LessThanMinimalResourcesError("VM should have \(VZVirtualMachineConfiguration.minimumAllowedCPUCount) CPU cores"
|
||||
+ " at minimum (requested \(cpuCount))")
|
||||
}
|
||||
|
||||
self.cpuCount = cpuCount
|
||||
}
|
||||
|
||||
mutating func setMemory(memorySize: UInt64) throws {
|
||||
if memorySize < memorySizeMin {
|
||||
if os == .darwin && memorySize < memorySizeMin {
|
||||
throw LessThanMinimalResourcesError("VM should have \(memorySizeMin) bytes"
|
||||
+ " of memory at minimum (requested \(memorySize))")
|
||||
}
|
||||
|
||||
if memorySize < VZVirtualMachineConfiguration.minimumAllowedMemorySize {
|
||||
throw LessThanMinimalResourcesError("VM should have \(VZVirtualMachineConfiguration.minimumAllowedMemorySize) bytes"
|
||||
+ " of memory at minimum (requested \(memorySize))")
|
||||
}
|
||||
|
||||
self.memorySize = memorySize
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,13 +30,17 @@ struct VMDirectory: Prunable {
|
||||
baseURL
|
||||
}
|
||||
|
||||
func lock() throws -> PIDLock {
|
||||
try PIDLock(lockURL: configURL)
|
||||
}
|
||||
|
||||
func running() throws -> Bool {
|
||||
// The most common reason why PIDLock() instantiation fails is a race with "tart delete" (ENOENT),
|
||||
// which is fine to report as "not running".
|
||||
//
|
||||
// The other reasons are unlikely and the cost of getting a false positive is way less than
|
||||
// the cost of crashing with an exception when calling "tart list" on a busy machine, for example.
|
||||
guard let lock = try? PIDLock(lockURL: configURL) else {
|
||||
guard let lock = try? lock() else {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -130,20 +134,45 @@ struct VMDirectory: Prunable {
|
||||
if !FileManager.default.fileExists(atPath: diskURL.path) {
|
||||
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
|
||||
}
|
||||
|
||||
let diskFileHandle = try FileHandle.init(forWritingTo: diskURL)
|
||||
// macOS considers kilo being 1000 and not 1024
|
||||
try diskFileHandle.truncate(atOffset: UInt64(sizeGB) * 1000 * 1000 * 1000)
|
||||
let currentDiskFileLength = try diskFileHandle.seekToEnd()
|
||||
let desiredDiskFileLength = UInt64(sizeGB) * 1000 * 1000 * 1000
|
||||
if desiredDiskFileLength < currentDiskFileLength {
|
||||
let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentDiskFileLength))
|
||||
let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredDiskFileLength))
|
||||
throw RuntimeError.InvalidDiskSize("new disk size of \(desiredLengthHuman) should be larger " +
|
||||
"than the current disk size of \(currentLengthHuman)")
|
||||
} else if desiredDiskFileLength > currentDiskFileLength {
|
||||
try diskFileHandle.truncate(atOffset: desiredDiskFileLength)
|
||||
}
|
||||
try diskFileHandle.close()
|
||||
}
|
||||
|
||||
func delete() throws {
|
||||
let lock = try lock()
|
||||
|
||||
if try !lock.trylock() {
|
||||
throw RuntimeError.VMIsRunning(name)
|
||||
}
|
||||
|
||||
try FileManager.default.removeItem(at: baseURL)
|
||||
|
||||
try lock.unlock()
|
||||
}
|
||||
|
||||
func accessDate() throws -> Date {
|
||||
try baseURL.accessDate()
|
||||
}
|
||||
|
||||
func allocatedSizeBytes() throws -> Int {
|
||||
try configURL.allocatedSizeBytes() + diskURL.allocatedSizeBytes() + nvramURL.allocatedSizeBytes()
|
||||
}
|
||||
|
||||
func allocatedSizeGB() throws -> Int {
|
||||
try allocatedSizeBytes() / 1000 / 1000 / 1000
|
||||
}
|
||||
|
||||
func sizeBytes() throws -> Int {
|
||||
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
|
||||
}
|
||||
|
||||
@@ -50,10 +50,12 @@ enum RuntimeError : Error {
|
||||
case VMConfigurationError(_ message: String)
|
||||
case VMDoesNotExist(name: String)
|
||||
case VMMissingFiles(_ message: String)
|
||||
case VMNotRunning(_ message: String)
|
||||
case VMIsRunning(_ name: String)
|
||||
case VMNotRunning(_ name: String)
|
||||
case VMAlreadyRunning(_ message: String)
|
||||
case NoIPAddressFound(_ message: String)
|
||||
case DiskAlreadyInUse(_ message: String)
|
||||
case InvalidDiskSize(_ message: String)
|
||||
case FailedToUpdateAccessDate(_ message: String)
|
||||
case PIDLockFailed(_ message: String)
|
||||
case FailedToParseRemoteName(_ message: String)
|
||||
@@ -81,14 +83,18 @@ extension RuntimeError : CustomStringConvertible {
|
||||
return "the specified VM \"\(name)\" does not exist"
|
||||
case .VMMissingFiles(let message):
|
||||
return message
|
||||
case .VMNotRunning(let message):
|
||||
return message
|
||||
case .VMIsRunning(let name):
|
||||
return "VM \"\(name)\" is running"
|
||||
case .VMNotRunning(let name):
|
||||
return "VM \"\(name)\" is not running"
|
||||
case .VMAlreadyRunning(let message):
|
||||
return message
|
||||
case .NoIPAddressFound(let message):
|
||||
return message
|
||||
case .DiskAlreadyInUse(let message):
|
||||
return message
|
||||
case .InvalidDiskSize(let message):
|
||||
return message
|
||||
case .FailedToUpdateAccessDate(let message):
|
||||
return message
|
||||
case .PIDLockFailed(let message):
|
||||
|
||||
@@ -39,7 +39,7 @@ class VMStorageLocal: PrunableStorage {
|
||||
}
|
||||
|
||||
func delete(_ name: String) throws {
|
||||
try FileManager.default.removeItem(at: vmURL(name))
|
||||
try VMDirectory(baseURL: vmURL(name)).delete()
|
||||
}
|
||||
|
||||
func list() throws -> [(String, VMDirectory)] {
|
||||
|
||||
@@ -219,9 +219,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
func link(from: RemoteName, to: RemoteName) throws {
|
||||
if FileManager.default.fileExists(atPath: vmURL(from).path) {
|
||||
try FileManager.default.removeItem(at: vmURL(from))
|
||||
}
|
||||
try? FileManager.default.removeItem(at: vmURL(from))
|
||||
|
||||
try FileManager.default.createSymbolicLink(at: vmURL(from), withDestinationURL: vmURL(to))
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
import Virtualization
|
||||
|
||||
final class DirectoryShareTests: XCTestCase {
|
||||
func testNamedParsing() throws {
|
||||
let share = try DirectoryShare(parseFrom: "build:/Users/admin/build")
|
||||
@@ -8,25 +10,65 @@ final class DirectoryShareTests: XCTestCase {
|
||||
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||
XCTAssertFalse(share.readOnly)
|
||||
}
|
||||
|
||||
|
||||
func testNamedReadOnlyParsing() throws {
|
||||
let share = try DirectoryShare(parseFrom: "build:/Users/admin/build:ro")
|
||||
XCTAssertEqual(share.name, "build")
|
||||
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||
XCTAssertTrue(share.readOnly)
|
||||
}
|
||||
|
||||
|
||||
func testOptionalNameParsing() throws {
|
||||
let share = try DirectoryShare(parseFrom: "/Users/admin/build")
|
||||
XCTAssertNil(share.name)
|
||||
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||
XCTAssertFalse(share.readOnly)
|
||||
}
|
||||
|
||||
|
||||
func testOptionalNameReadOnlyParsing() throws {
|
||||
let share = try DirectoryShare(parseFrom: "/Users/admin/build:ro")
|
||||
XCTAssertNil(share.name)
|
||||
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||
XCTAssertTrue(share.readOnly)
|
||||
}
|
||||
|
||||
func testMountTagParsing() throws {
|
||||
let share = try DirectoryShare(parseFrom: "/Users/admin/build:tag=foo-bar")
|
||||
XCTAssertNil(share.name)
|
||||
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||
XCTAssertFalse(share.readOnly)
|
||||
XCTAssertEqual(share.mountTag, "foo-bar")
|
||||
|
||||
let roShare = try DirectoryShare(parseFrom: "/Users/admin/build:ro,tag=foo-bar")
|
||||
XCTAssertNil(roShare.name)
|
||||
XCTAssertEqual(roShare.path, URL(filePath: "/Users/admin/build"))
|
||||
XCTAssertTrue(roShare.readOnly)
|
||||
XCTAssertEqual(roShare.mountTag, "foo-bar")
|
||||
|
||||
let inverseRoShare = try DirectoryShare(parseFrom: "/Users/admin/build:tag=foo-bar,ro")
|
||||
XCTAssertNil(inverseRoShare.name)
|
||||
XCTAssertEqual(inverseRoShare.path, URL(filePath: "/Users/admin/build"))
|
||||
XCTAssertTrue(inverseRoShare.readOnly)
|
||||
XCTAssertEqual(inverseRoShare.mountTag, "foo-bar")
|
||||
}
|
||||
|
||||
func testURL() throws {
|
||||
let archiveWithoutNameOrOptions = try DirectoryShare(parseFrom: "https://example.com/archive.tar.gz")
|
||||
XCTAssertNil(archiveWithoutNameOrOptions.name)
|
||||
XCTAssertEqual(archiveWithoutNameOrOptions.path, URL(string: "https://example.com/archive.tar.gz")!)
|
||||
XCTAssertFalse(archiveWithoutNameOrOptions.readOnly)
|
||||
XCTAssertEqual(archiveWithoutNameOrOptions.mountTag, VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag)
|
||||
|
||||
let archiveWithOptions = try DirectoryShare(parseFrom: "https://example.com/archive.tar.gz:ro,tag=sometag")
|
||||
XCTAssertNil(archiveWithOptions.name)
|
||||
XCTAssertEqual(archiveWithOptions.path, URL(string: "https://example.com/archive.tar.gz")!)
|
||||
XCTAssertTrue(archiveWithOptions.readOnly)
|
||||
XCTAssertEqual(archiveWithOptions.mountTag, "sometag")
|
||||
|
||||
let archiveWithNameAndOptions = try DirectoryShare(parseFrom: "somename:https://example.com/archive.tar.gz:ro,tag=sometag")
|
||||
XCTAssertEqual(archiveWithNameAndOptions.name, "somename")
|
||||
XCTAssertEqual(archiveWithNameAndOptions.path, URL(string: "https://example.com/archive.tar.gz")!)
|
||||
XCTAssertTrue(archiveWithNameAndOptions.readOnly)
|
||||
XCTAssertEqual(archiveWithNameAndOptions.mountTag, "sometag")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
import Network
|
||||
import SwiftRadix
|
||||
|
||||
final class LeaseTests: XCTestCase {
|
||||
func testCorrectTimezone() throws {
|
||||
let lease = Lease(fromRawLease: [
|
||||
"hw_address": "1,11:22:33:44:55:66",
|
||||
"ip_address": "1.2.3.4",
|
||||
"lease": "0x6565da9e",
|
||||
])
|
||||
|
||||
XCTAssertNotNil(lease)
|
||||
XCTAssertEqual(lease!.expiresAt.toISO(), "2023-11-28T12:18:38Z")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
import Network
|
||||
import SwiftDate
|
||||
|
||||
final class LeasesTests: XCTestCase {
|
||||
func testNoExpired() throws {
|
||||
let macAddress = MACAddress(fromString: "11:22:33:44:55:66")!
|
||||
|
||||
let leases = try Leases("""
|
||||
{
|
||||
name=whatever
|
||||
ip_address=66.66.66.66
|
||||
hw_address=1,\(macAddress)
|
||||
identifier=1,\(macAddress)
|
||||
lease=\(Int((Date() - 1.seconds).timeIntervalSince1970).hex)
|
||||
|
||||
}
|
||||
{
|
||||
name=whatever
|
||||
ip_address=1.2.3.4
|
||||
hw_address=1,\(macAddress)
|
||||
identifier=1,\(macAddress)
|
||||
lease=\(Int((Date() + 10.minutes).timeIntervalSince1970).hex)
|
||||
}
|
||||
{
|
||||
name=whatever
|
||||
ip_address=66.66.66.66
|
||||
hw_address=1,\(macAddress)
|
||||
identifier=1,\(macAddress)
|
||||
lease=\(Int((Date() - 1.seconds).timeIntervalSince1970).hex)
|
||||
}
|
||||
""")
|
||||
|
||||
XCTAssertEqual(IPv4Address("1.2.3.4"), leases.ResolveMACAddress(macAddress: macAddress))
|
||||
}
|
||||
|
||||
func testDuplicateYetNotExpiredLeases() throws {
|
||||
let macAddress = MACAddress(fromString: "11:22:33:44:55:66")!
|
||||
|
||||
let leases = try Leases("""
|
||||
{
|
||||
name=debian
|
||||
ip_address=192.168.64.1
|
||||
hw_address=1,\(macAddress)
|
||||
identifier=1,\(macAddress)
|
||||
lease=\(Int((Date() + 10.minutes).timeIntervalSince1970).hex)
|
||||
}
|
||||
{
|
||||
name=debian
|
||||
ip_address=192.168.64.2
|
||||
hw_address=1,\(macAddress)
|
||||
identifier=1,\(macAddress)
|
||||
lease=\(Int((Date() + 5.minutes).timeIntervalSince1970).hex)
|
||||
}
|
||||
""")
|
||||
|
||||
XCTAssertEqual(IPv4Address("192.168.64.1"), leases.ResolveMACAddress(macAddress: macAddress))
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,12 @@ final class MACAddressResolverTests: XCTestCase {
|
||||
{
|
||||
ip_address=1.2.3.4
|
||||
hw_address=1,00:11:22:33:44:55
|
||||
lease=0x7fffffff
|
||||
}
|
||||
""")
|
||||
|
||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
||||
try leases.ResolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||
leases.ResolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||
}
|
||||
|
||||
func testMultipleEntries() throws {
|
||||
@@ -20,16 +21,18 @@ final class MACAddressResolverTests: XCTestCase {
|
||||
{
|
||||
ip_address=1.2.3.4
|
||||
hw_address=1,00:11:22:33:44:55
|
||||
lease=0x7fffffff
|
||||
}
|
||||
{
|
||||
ip_address=5.6.7.8
|
||||
hw_address=1,AA:BB:CC:DD:EE:FF
|
||||
lease=0x7fffffff
|
||||
}
|
||||
""")
|
||||
|
||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
||||
try leases.ResolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||
leases.ResolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||
XCTAssertEqual(IPv4Address("5.6.7.8"),
|
||||
try leases.ResolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!))
|
||||
leases.ResolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!))
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 538 KiB |
@@ -62,7 +62,7 @@ and before reaching the 100 CPU cores limit is royalty-free and does not have th
|
||||
|
||||
When an organization surpasses the 100 CPU cores limit, they will be required to obtain a [Gold Tier License](/licensing#license-tiers),
|
||||
which costs \$1000 per month. Upon reaching a limit of 500 CPU cores, a [Platinum Tier License](/licensing#license-tiers)
|
||||
(\$5000 per month) will be required, and for organizations that exceed 5000 CPU cores, a custom [Diamond Tier License](/licensing#license-tiers)
|
||||
(\$3000 per month) will be required, and for organizations that exceed 3000 CPU cores, a custom [Diamond Tier License](/licensing#license-tiers)
|
||||
(\$1 per core per month) will be necessary. **All paid license tiers will include priority feature development and SLAs on support with urgent issues.**
|
||||
|
||||
## Have we considered alternatives?
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
draft: false
|
||||
date: 2023-11-03
|
||||
search:
|
||||
exclude: true
|
||||
authors:
|
||||
- fkorotkov
|
||||
categories:
|
||||
- announcement
|
||||
---
|
||||
|
||||
# New dashboard with insights into performance of Cirrus Runners
|
||||
|
||||
This month we are celebrating one year since launching Cirrus Runners — managed Apple Silicon infrastructure for your
|
||||
GitHub Actions. During the last 12 months we ran millions of workflows for our customers and now ready to share some insights
|
||||
into price performance of them for our customers.
|
||||
|
||||
One of the key difference with Cirrus Runners is how they are getting billed for. Customers purchase Cirrus Runners via monthly subscription
|
||||
that costs $150 per each Cirrus Runner. Each runner can be used 24 hours a day 7 days a week to run GitHub Actions workflows
|
||||
for an organization. If there are more outstanding jobs than available runners then they are queued and executed as soon as
|
||||
there is a free runner. This is different from how GitHub-managed GitHub Actions are billed for — you pay for each minute of execution time.
|
||||
|
||||
The benefit of a fixed price is that you can run as many jobs as you want without worrying about the cost. The downside is that
|
||||
you need to make sure that you are using your runners efficiently. This is where the new dashboard comes in handy.
|
||||
|
||||
<!-- more -->
|
||||
|
||||
But first, **let's see theoretically the lowest price per minute** of a Cirrus Runners. If you run 24 hours a day 7 days a week
|
||||
then you will get 43,200 minutes of execution time per month. This means that the price per minute is $0.0035 if your runners
|
||||
utilization is 100%. But even if your engineering teams is located in a single time zone and works 8 hours a day 5 days a week
|
||||
then you will get 9,600 minutes of execution time per month which comes down to $0.015 per-minute. This is still more than 10 times cheaper
|
||||
than recently announced Apple Silicon GitHub-manged runners that cost $0.16 per minute.
|
||||
|
||||
Now lets take a look at the new Cirrus Runners dashboard of a real customers that run their workflows on Cirrus Runners
|
||||
and **practically pushing the price performance pretty close to the theoretical minimum**.
|
||||
|
||||

|
||||
|
||||
As you can see above Cirrus Runners Dashboard focuses on 4 core metrics:
|
||||
|
||||
1. **Minutes Used** — overall amount of minutes that Cirrus Runners were executing jobs.
|
||||
2. **Workflow Runs** — absolute number of workflow runs that were executed on Cirrus Runners.
|
||||
3. **Queue Size** — number of jobs that were queued and waiting for a free Cirrus Runner.
|
||||
4. **Queue Time** — average time that jobs were waiting in the queue.
|
||||
|
||||
In this particular example price performance of Cirrus Runners is $0.006 per minute which is 2 times more than the theoretical minimum
|
||||
and **26 times better than GitHub-managed Apple Silicon runners**. But this is a extreme example, looking at queue time and queue size
|
||||
we can see that the downside of such great price performance is that jobs are waiting in the queue on average around 5 minutes.
|
||||
|
||||
Here is another example of Cirrus Runners Dashboard for a different customer that has a slightly higher price performance of $0.017 per minute
|
||||
but at the same time doesn't experience queue time at all. **Note that $0.017 is still 10 times cheaper than GitHub-managed Apple Silicon runners**.
|
||||
|
||||

|
||||
|
||||
## Conclusion
|
||||
|
||||
Having a fixed price for Cirrus Runners is a great way to save money on your CI/CD infrastructure and just in general have predictable budged.
|
||||
But it requires keeping the balance between price per minute and queue time. Cirrus Runners Dashboard helps you to keep an eye on this balance
|
||||
and make sure that you are getting the most out of your Cirrus Runners.
|
||||
@@ -3,19 +3,9 @@ hide:
|
||||
- navigation
|
||||
---
|
||||
|
||||
## How Tart is different from Anka?
|
||||
|
||||
Under the hood Tart is using the same technology as Anka 3.0 so there should be no real difference in performance
|
||||
or features supported. If there is some feature missing please don't hesitate to [create a feature request](https://github.com/cirruslabs/tart/issues).
|
||||
|
||||
Instead of Anka Registry, Tart can work with any OCI-compatible container registry. This provides a much more consistent
|
||||
and scalable experience for distributing virtual machines.
|
||||
|
||||
Tart doesn't yet have an analogue of Anka Controller for managing long living VMs but [soon will be](https://github.com/cirruslabs/tart/issues/372).
|
||||
|
||||
## VM location on disk
|
||||
|
||||
Tart stores all it's files in `~/.tart/` directory. Local images that you can run are stored in `~/.tart/vms/`.
|
||||
Tart stores all its files in `~/.tart/` directory. Local images that you can run are stored in `~/.tart/vms/`.
|
||||
Remote images are pulled into `~/.tart/cache/OCIs/`.
|
||||
|
||||
## Nested virtualization support?
|
||||
@@ -66,3 +56,35 @@ sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.InternetS
|
||||
```
|
||||
|
||||
Note that this tweak persists across reboots, so normally you'll only need to do it once per new host.
|
||||
|
||||
## Running login/clone/pull/push commands over SSH
|
||||
|
||||
When invoking the Tart in an SSH session, you might get error like this:
|
||||
|
||||
>Keychain returned unsuccessful status -25308
|
||||
|
||||
...or this:
|
||||
|
||||
>Keychain failed to update item: User interaction is not allowed.
|
||||
|
||||
This is because Tart uses [Keychain](https://en.wikipedia.org/wiki/Keychain_(software)) to store and retrieve OCI registry credentials by default, but Keychain is only automatically/semi-automatically unlocked in GUI sessions.
|
||||
|
||||
To unlock the Keychain in an SSH session, run the following command, which will ask for your user's password:
|
||||
|
||||
```shell
|
||||
security unlock-keychain
|
||||
```
|
||||
|
||||
This command also supports the `-p` command-line argument that allows you to supply the password and unlock non-interactively, which is great for scripts.
|
||||
|
||||
If that doesn't work for you for some reason, you can pass the credentials via the environment variables, see [Registry Authorization](integrations/vm-management.md#registry-authorization) for more details on how to do that.
|
||||
|
||||
## How Tart is different from Anka?
|
||||
|
||||
Under the hood Tart is using the same technology as Anka 3.0 so there should be no real difference in performance
|
||||
or features supported. If there is some feature missing please don't hesitate to [create a feature request](https://github.com/cirruslabs/tart/issues).
|
||||
|
||||
Instead of Anka Registry, Tart can work with any OCI-compatible container registry. This provides a much more consistent
|
||||
and scalable experience for distributing virtual machines.
|
||||
|
||||
Tart doesn't yet have an analogue of Anka Controller for managing long living VMs but [soon will be](https://github.com/cirruslabs/tart/issues/372).
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Buildkite
|
||||
|
||||
It is possible to run [Buildkite](https://buildkite.com/) pipeline steps in isolated ephemeral Tart Virtual Machines with the help of [Tart Buildkite Plugin](https://github.com/cirruslabs/tart-buildkite-plugin):
|
||||
|
||||

|
||||
|
||||
## Configuration
|
||||
|
||||
The most basic configuration looks like this:
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- command: uname -a
|
||||
plugins:
|
||||
- cirruslabs/tart#main:
|
||||
image: ghcr.io/cirruslabs/macos-sonoma-base:latest
|
||||
```
|
||||
|
||||
This will run `uname -r` in a macOS Tart VM cloned from `ghcr.io/cirruslabs/macos-sonoma-base:latest`.
|
||||
|
||||
See plugin's [Configuration section](https://github.com/cirruslabs/tart-buildkite-plugin#configuration) for the full list of available options.
|
||||
@@ -1,10 +1,14 @@
|
||||
# Cirrus Runners for GitHub Actions
|
||||
|
||||
*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.
|
||||
*Cirrus Runners* is the fastest and most cost-efficient 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.
|
||||
|
||||
## Testimonials from customers
|
||||
|
||||
Mitchell Hashimoto, HashiCorp co-founder:
|
||||
|
||||
> I've been using "Cirrus Runners" since [that tweet](https://twitter.com/mitchellh/status/1731071326201561194) and it has been fantastic. Huge speed increase, huge cost decrease, zero maintenance, exactly what I wanted.
|
||||
|
||||
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.
|
||||
@@ -24,6 +28,15 @@ We recommend to purchase several Cirrus Runners depending on your team size, so
|
||||
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).
|
||||
|
||||
### Discounts
|
||||
|
||||
We offer two mutually exclusive discounts:
|
||||
|
||||
- 10% "Volume Discount" for subscriptions of 10 or more Cirrus Runners.
|
||||
- 15% "Annual Discount" for 12 months subscription commitment of any amount of Cirrus Runners.
|
||||
|
||||
Please contact [support@cirruslabs.org](mailto:support@cirruslabs.org) after activating the subscription in order to get the discount applied.
|
||||
|
||||
### Priority Support
|
||||
|
||||
Subscriptions of 20 or more Cirrus Runners include access to [Priority Support](../licensing.md#priority-support).
|
||||
@@ -84,6 +97,14 @@ Note that Cirrus Runners will get added to the default runner group.
|
||||
|
||||

|
||||
|
||||
### Dashboard
|
||||
|
||||
You can also see the status of your runners on the [Cirrus Runners Dashboard](https://cirrus-runners.app/). This dashboard
|
||||
also provides insights into price performance of your Cirrus Runners. Please check out [this blog post](/blog/2023/11/03/new-dashboard-with-insights-into-performance-of-cirrus-runners/)
|
||||
to learn more about what this dashboard can do for you.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||
@@ -19,7 +19,7 @@ concurrent = 2
|
||||
[[runners]]
|
||||
# ...
|
||||
executor = "custom"
|
||||
builds_dir = "/Users/admin/builds" # directory inside the
|
||||
builds_dir = "/Users/admin/builds" # directory inside the VM
|
||||
cache_dir = "/Users/admin/cache"
|
||||
[runners.feature_flags]
|
||||
FF_RESOLVE_FULL_TLS_CHAIN = false
|
||||
|
||||
@@ -13,9 +13,7 @@ will be required to obtain a paid license.
|
||||
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
|
||||
|
||||
## Free Tier
|
||||
## License Tiers
|
||||
|
||||
By default, when no [license is purchased](#get-the-license), it is assumed that an organization is using a Free Tier license.
|
||||
You can find the Free Tier license text in [Tart](https://github.com/cirruslabs/tart/blob/main/LICENSE) and [Orchard](https://github.com/cirruslabs/orchard/blob/main/LICENSE) repositories.
|
||||
@@ -34,23 +32,23 @@ Free Tier license has a 100 CPU core limit for Tart and 4 Orchard Workers limit
|
||||
- Using Tart on 13 Mac Minis with 8 CPUs each.
|
||||
- Creating an Orchard cluster of 5 Mac Minis workers with 8 CPUs each.
|
||||
|
||||
## Gold Tier
|
||||
### Gold Tier
|
||||
|
||||
If an organization wishes to exceed the limits of the Free Tier license, a purchase of the [Gold Tier License](#get-the-license) is required, which costs \$1000 per month.
|
||||
|
||||
Gold Tier license has a 500 CPU core limit for Tart and 20 Orchard Workers limit for Orchard.
|
||||
|
||||
## Platinum Tier
|
||||
### Platinum Tier
|
||||
|
||||
If an organization wishes to exceed the limits of the Gold Tier license, a purchase of the [Platinum Tier License](#get-the-license) is required, which costs \$5000 per month.
|
||||
If an organization wishes to exceed the limits of the Gold Tier license, a purchase of the [Platinum Tier License](#get-the-license) is required, which costs \$3000 per month.
|
||||
|
||||
Platinum Tier license has a 5,000 CPU core limit for Tart and 200 Orchard Workers limit for Orchard.
|
||||
Platinum Tier license has a 3,000 CPU core limit for Tart and 200 Orchard Workers limit for Orchard.
|
||||
|
||||
## Diamond Tier
|
||||
### Diamond Tier
|
||||
|
||||
For organizations that wish to exceed the limits of the Platinum Tier license, a purchase of a [custom Diamond Tier License](#get-the-license) is required, which costs \$1 per CPU core per month and gives the ability to run unlimited Orchard Workers.
|
||||
|
||||
# Get the license
|
||||
## Get the license
|
||||
|
||||
If your organization is interested in purchasing one of the license tiers, please email [licensing@cirruslabs.org](mailto:licensing@cirruslabs.org).
|
||||
|
||||
@@ -61,7 +59,10 @@ You can see a template of a license subscription agreement [here](assets/TartLic
|
||||
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
|
||||
Additionally, there is a [ECR Pulic Gallery mirror](https://gallery.ecr.aws/cirruslabs/macos) of all the
|
||||
[Tart VM images managed by us](https://github.com/cirruslabs/macos-image-templates).
|
||||
|
||||
## 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).
|
||||
Our support team is trying our best to respond ASAP, but there is no guarantee on a response time unless your organization
|
||||
@@ -70,7 +71,7 @@ has a paid license subscription which includes [Priority Support](#priority-supp
|
||||
If you have a feature request or noticed lack of some documentation please feel free to [create a GitHub issue](https://github.com/cirruslabs/tart/issues/new).
|
||||
Our support team will answer it by replying to the issue or by updating the documentation.
|
||||
|
||||
# Priority Support
|
||||
## Priority Support
|
||||
|
||||
In addition to the general support we provide a *Priority Support* with guaranteed response times included in all the paid license tiers.
|
||||
|
||||
|
||||
@@ -28,6 +28,53 @@ tart run sonoma-base
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartScreenshot.png"/>
|
||||
</p>
|
||||
|
||||
## VM images
|
||||
|
||||
The following macOS images are currently available:
|
||||
|
||||
* macOS 14 (Sonoma)
|
||||
* `ghcr.io/cirruslabs/macos-sonoma-vanilla:latest`
|
||||
* `ghcr.io/cirruslabs/macos-sonoma-base:latest`
|
||||
* `ghcr.io/cirruslabs/macos-sonoma-xcode:latest`
|
||||
* macOS 13 (Ventura)
|
||||
* `ghcr.io/cirruslabs/macos-ventura-vanilla:latest`
|
||||
* `ghcr.io/cirruslabs/macos-ventura-base:latest`
|
||||
* `ghcr.io/cirruslabs/macos-ventura-xcode:latest`
|
||||
* macOS 12 (Monterey)
|
||||
* `ghcr.io/cirruslabs/macos-monterey-vanilla:latest`
|
||||
* `ghcr.io/cirruslabs/macos-monterey-base:latest`
|
||||
* `ghcr.io/cirruslabs/macos-monterey-xcode:latest`
|
||||
|
||||
There's also a [full list of images](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos-) in which you can discovery specific tags (e.g. `ghcr.io/cirruslabs/macos-monterey-xcode:15`) and [macOS-specific Packer templates](https://github.com/cirruslabs/macos-image-templates) that were used to generate these images.
|
||||
|
||||
For, Linux the options are as follows:
|
||||
|
||||
* Ubuntu
|
||||
* `ghcr.io/cirruslabs/ubuntu:latest`
|
||||
* Debian
|
||||
* `ghcr.io/cirruslabs/debian:latest`
|
||||
* Fedora
|
||||
* `ghcr.io/cirruslabs/fedora:latest`
|
||||
|
||||
Note that these Linux images have a minimal disk size of 20 GB, and you might want to resize them right after cloning:
|
||||
|
||||
```bash
|
||||
tart clone ghcr.io/cirruslabs/ubuntu:latest ubuntu
|
||||
tart set ubuntu --disk-size 50
|
||||
tart run ubuntu
|
||||
```
|
||||
|
||||
These Linux images can be ran natively on [Vetu](https://github.com/cirruslabs/vetu), our virtualization solution for Linux, assuming that Vetu itself is running on an `arm64` machine.
|
||||
|
||||
Similarly to macOS, there's also a [full list of images](https://github.com/orgs/cirruslabs/packages?repo_name=linux-image-templates) in which you can discovery specific tags (e.g. `ghcr.io/cirruslabs/ubuntu:22.04`) and [Linux-specific Packer templates](https://github.com/cirruslabs/linux-image-templates) that were used to generate these images.
|
||||
|
||||
All images above use the following credentials:
|
||||
|
||||
* Username: `admin`
|
||||
* Password: `admin`
|
||||
|
||||
These credentials work both for logging in via GUI, console (Linux) and SSH.
|
||||
|
||||
## SSH access
|
||||
|
||||
If the guest VM is running and configured to accept incoming SSH connections you can conveniently connect to it like so:
|
||||
@@ -41,7 +88,7 @@ ssh admin@$(tart ip sonoma-base)
|
||||
from within Tart virtual machines. Alternatively, you can use plain ssh connection and `tart ip` command:
|
||||
|
||||
```bash
|
||||
brew install sshpass
|
||||
brew install cirruslabs/cli/sshpass
|
||||
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
|
||||
```
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block announce %}
|
||||
<a href="/blog/2023/10/06/tart-is-now-available-on-aws-marketplace/">
|
||||
☁️☁️☁️  Tart is now available on <strong>AWS Marketplace</strong> ☁️☁️☁️
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
<!-- Render landing page under tabs -->
|
||||
{% block tabs %} {{ super() }}
|
||||
|
||||
@@ -177,15 +171,10 @@
|
||||
<figcaption class="md-typeset">
|
||||
<h2>Seamless integration with your existing automations</h2>
|
||||
<p>
|
||||
Tart powers several continuous integration systems including
|
||||
<a href="/integrations/github-actions"
|
||||
>on‑demand GitHub Actions Runners</a
|
||||
>
|
||||
and
|
||||
<a href="https://cirrus-ci.org/guide/macOS/" target="_blank"
|
||||
>Cirrus CI</a
|
||||
>. Double the performance of your macOS actions with
|
||||
a couple lines of code.
|
||||
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>
|
||||
to run your workflows.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
@@ -275,6 +264,26 @@
|
||||
</h1>
|
||||
</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"
|
||||
/>
|
||||
<figcaption class="md-typeset">
|
||||
<h2>Mitchell Hashimoto</h2>
|
||||
<h3>
|
||||
<a href="https://www.hashicorp.com/" target="_blank">HashiCorp</a> co-founder
|
||||
</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.
|
||||
</cite>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<figure class="mdx-users__testimonial">
|
||||
<img
|
||||
src="assets/images/users/seb-jachec.webp"
|
||||
@@ -299,30 +308,6 @@
|
||||
</cite>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<figure class="mdx-users__testimonial">
|
||||
<img
|
||||
src="assets/images/users/mikhail-tokarev.webp"
|
||||
alt="Mikhail Tokarev"
|
||||
loading="lazy"
|
||||
width="200"
|
||||
height="200"
|
||||
/>
|
||||
<figcaption class="md-typeset">
|
||||
<h2>Mikhail Tokarev</h2>
|
||||
<h3>
|
||||
CTO at
|
||||
<a href="https://codemagic.io/start/" target="_blank"
|
||||
>Codemagic</a
|
||||
>
|
||||
</h3>
|
||||
<hr/>
|
||||
<cite>
|
||||
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/max-lapides.webp"
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
source = [".build/arm64-apple-macosx/release/tart"]
|
||||
source = [
|
||||
".build/x86_64-apple-macosx/release/tart",
|
||||
".build/arm64-apple-macosx/release/tart"
|
||||
]
|
||||
bundle_id = "com.github.cirruslabs.tart"
|
||||
|
||||
apple_id {
|
||||
|
||||
@@ -91,6 +91,7 @@ nav:
|
||||
- "Integrations":
|
||||
- "GitHub Actions": integrations/github-actions.md
|
||||
- "GitLab Runner": integrations/gitlab-runner.md
|
||||
- "Buildkite": integrations/buildkite.md
|
||||
- "Self-hosted CI": integrations/cirrus-cli.md
|
||||
- "Managing VMs": integrations/vm-management.md
|
||||
- "Support & Licensing": licensing.md
|
||||
|
||||
@@ -8,4 +8,8 @@ set -e
|
||||
swift build --product tart
|
||||
codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
||||
|
||||
.build/debug/tart "$@"
|
||||
mkdir -p .build/tart.app/Contents/MacOS
|
||||
cp -c .build/debug/tart .build/tart.app/Contents/MacOS/tart
|
||||
cp -c Resources/embedded.provisionprofile .build/tart.app/Contents/embedded.provisionprofile
|
||||
|
||||
.build/tart.app/Contents/MacOS/tart "$@"
|
||||
|
||||