Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
d8b69de52d | ||
|
|
c4c2bfeded | ||
|
|
b95585b56b | ||
|
|
8d5574ed3f | ||
|
|
457c2bc7db | ||
|
|
4bf9bdd531 | ||
|
|
8e9d61d5f5 |
@@ -83,8 +83,9 @@ task:
|
|||||||
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
|
- 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
|
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
|
||||||
install_script:
|
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
|
- brew install mitchellh/gon/gon
|
||||||
|
- curl -sL https://sentry.io/get-cli/ | sh
|
||||||
info_script:
|
info_script:
|
||||||
- security find-identity -v
|
- security find-identity -v
|
||||||
- xcodebuild -version
|
- xcodebuild -version
|
||||||
|
|||||||
@@ -108,6 +108,15 @@
|
|||||||
"version" : "0.50.6"
|
"version" : "0.50.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swiftradix",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/orchetect/SwiftRadix",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a52c37a4c213403f7377ae77b4c68451bcab8330",
|
||||||
|
"version" : "1.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "texttable",
|
"identity" : "texttable",
|
||||||
"kind" : "remoteSourceControl",
|
"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/getsentry/sentry-cocoa", from: "8.8.0"),
|
||||||
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
|
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
|
||||||
.package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.0.0"),
|
.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: [
|
targets: [
|
||||||
.executableTarget(name: "tart", dependencies: [
|
.executableTarget(name: "tart", dependencies: [
|
||||||
@@ -34,6 +35,7 @@ let package = Package(
|
|||||||
.product(name: "Sentry", package: "sentry-cocoa"),
|
.product(name: "Sentry", package: "sentry-cocoa"),
|
||||||
.product(name: "TextTable", package: "TextTable"),
|
.product(name: "TextTable", package: "TextTable"),
|
||||||
.product(name: "Sysctl", package: "swift-sysctl"),
|
.product(name: "Sysctl", package: "swift-sysctl"),
|
||||||
|
.product(name: "SwiftRadix", package: "SwiftRadix"),
|
||||||
], exclude: [
|
], exclude: [
|
||||||
"OCI/Reference/Makefile",
|
"OCI/Reference/Makefile",
|
||||||
"OCI/Reference/Reference.g4",
|
"OCI/Reference/Reference.g4",
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ Many more companies are using Tart in their internal setups. Here are a few of t
|
|||||||
<a href="https://mullvad.net/" target=_blank>
|
<a href="https://mullvad.net/" target=_blank>
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Mullvad.png" height="65"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Mullvad.png" height="65"/>
|
||||||
</a>
|
</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>
|
<a href="https://suran.com/" target=_blank>
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Suran.png" height="65"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Suran.png" height="65"/>
|
||||||
</a>
|
</a>
|
||||||
@@ -62,6 +65,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).
|
**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
|
## Usage
|
||||||
|
|
||||||
Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura) or later (will download a 25 GB image):
|
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 |
@@ -4,5 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.virtualization</key>
|
<key>com.apple.security.virtualization</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.get-task-allow</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ struct Create: AsyncParsableCommand {
|
|||||||
} else if fromIPSW.starts(with: "http://") || fromIPSW.starts(with: "https://") {
|
} else if fromIPSW.starts(with: "http://") || fromIPSW.starts(with: "https://") {
|
||||||
ipswURL = URL(string: fromIPSW)!
|
ipswURL = URL(string: fromIPSW)!
|
||||||
} else {
|
} else {
|
||||||
ipswURL = URL(fileURLWithPath: fromIPSW)
|
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)
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ struct Run: AsyncParsableCommand {
|
|||||||
@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\"."))
|
@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\"."))
|
||||||
var suspendable: Bool = false
|
var suspendable: Bool = false
|
||||||
|
|
||||||
|
@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."))
|
||||||
|
var captureSystemKeys: Bool = false
|
||||||
|
|
||||||
mutating func validate() throws {
|
mutating func validate() throws {
|
||||||
if vnc && vncExperimental {
|
if vnc && vncExperimental {
|
||||||
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
|
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
|
||||||
@@ -121,11 +125,27 @@ struct Run: AsyncParsableCommand {
|
|||||||
throw ValidationError("--graphics and --no-graphics are mutually exclusive")
|
throw ValidationError("--graphics and --no-graphics are mutually exclusive")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (noGraphics || vnc || vncExperimental) && captureSystemKeys {
|
||||||
|
throw ValidationError("--captures-system-keys can only be used with the default VM view")
|
||||||
|
}
|
||||||
|
|
||||||
let localStorage = VMStorageLocal()
|
let localStorage = VMStorageLocal()
|
||||||
let vmDir = try localStorage.open(name)
|
let vmDir = try localStorage.open(name)
|
||||||
if try vmDir.state() == "suspended" {
|
if try vmDir.state() == "suspended" {
|
||||||
suspendable = true
|
suspendable = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if suspendable {
|
||||||
|
if dir.count > 0 {
|
||||||
|
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
|
@MainActor
|
||||||
@@ -204,7 +224,7 @@ struct Run: AsyncParsableCommand {
|
|||||||
// configuration file, otherwise we will loose the lock.
|
// configuration file, otherwise we will loose the lock.
|
||||||
//
|
//
|
||||||
// [1]: https://man.openbsd.org/fcntl
|
// [1]: https://man.openbsd.org/fcntl
|
||||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
let lock = try vmDir.lock()
|
||||||
if try !lock.trylock() {
|
if try !lock.trylock() {
|
||||||
throw RuntimeError.VMAlreadyRunning("VM \"\(name)\" is already running!")
|
throw RuntimeError.VMAlreadyRunning("VM \"\(name)\" is already running!")
|
||||||
}
|
}
|
||||||
@@ -298,9 +318,13 @@ struct Run: AsyncParsableCommand {
|
|||||||
|
|
||||||
let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics
|
let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics
|
||||||
if noGraphics || useVNCWithoutGraphics {
|
if noGraphics || useVNCWithoutGraphics {
|
||||||
dispatchMain()
|
// 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 {
|
} else {
|
||||||
runUI(suspendable)
|
runUI(suspendable, captureSystemKeys)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,7 +485,7 @@ struct Run: AsyncParsableCommand {
|
|||||||
return [device]
|
return [device]
|
||||||
}
|
}
|
||||||
|
|
||||||
private func runUI(_ suspendable: Bool) {
|
private func runUI(_ suspendable: Bool, _ captureSystemKeys: Bool) {
|
||||||
let nsApp = NSApplication.shared
|
let nsApp = NSApplication.shared
|
||||||
nsApp.setActivationPolicy(.regular)
|
nsApp.setActivationPolicy(.regular)
|
||||||
nsApp.activate(ignoringOtherApps: true)
|
nsApp.activate(ignoringOtherApps: true)
|
||||||
@@ -470,13 +494,14 @@ struct Run: AsyncParsableCommand {
|
|||||||
|
|
||||||
struct MainApp: App {
|
struct MainApp: App {
|
||||||
static var disappearSignal: Int32 = SIGINT
|
static var disappearSignal: Int32 = SIGINT
|
||||||
|
static var capturesSystemKeys: Bool = false
|
||||||
|
|
||||||
@NSApplicationDelegateAdaptor private var appDelegate: MinimalMenuAppDelegate
|
@NSApplicationDelegateAdaptor private var appDelegate: MinimalMenuAppDelegate
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup(vm!.name) {
|
WindowGroup(vm!.name) {
|
||||||
Group {
|
Group {
|
||||||
VMView(vm: vm!).onAppear {
|
VMView(vm: vm!, capturesSystemKeys: MainApp.capturesSystemKeys).onAppear {
|
||||||
NSWindow.allowsAutomaticWindowTabbing = false
|
NSWindow.allowsAutomaticWindowTabbing = false
|
||||||
}.onDisappear {
|
}.onDisappear {
|
||||||
let ret = kill(getpid(), MainApp.disappearSignal)
|
let ret = kill(getpid(), MainApp.disappearSignal)
|
||||||
@@ -526,6 +551,7 @@ struct Run: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
MainApp.disappearSignal = suspendable ? SIGUSR1 : SIGINT
|
MainApp.disappearSignal = suspendable ? SIGUSR1 : SIGINT
|
||||||
|
MainApp.capturesSystemKeys = captureSystemKeys
|
||||||
MainApp.main()
|
MainApp.main()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -575,18 +601,19 @@ struct VMView: NSViewRepresentable {
|
|||||||
typealias NSViewType = VZVirtualMachineView
|
typealias NSViewType = VZVirtualMachineView
|
||||||
|
|
||||||
@ObservedObject var vm: VM
|
@ObservedObject var vm: VM
|
||||||
|
var capturesSystemKeys: Bool
|
||||||
|
|
||||||
func makeNSView(context: Context) -> NSViewType {
|
func makeNSView(context: Context) -> NSViewType {
|
||||||
let machineView = VZVirtualMachineView()
|
let machineView = VZVirtualMachineView()
|
||||||
|
|
||||||
// Do not capture system keys so that shortcuts like
|
machineView.capturesSystemKeys = capturesSystemKeys
|
||||||
// Shift-Command-4 + Space (capture a screenshot of window)
|
|
||||||
// work on the host instead of the guest
|
|
||||||
machineView.capturesSystemKeys = false
|
|
||||||
|
|
||||||
// Enable automatic display reconfiguration
|
// Enable automatic display reconfiguration
|
||||||
// for guests that support it
|
// 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
|
machineView.automaticallyReconfiguresDisplay = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,7 +662,7 @@ struct DirectoryShare {
|
|||||||
|
|
||||||
let archiveRequest = URLRequest(url: path, cachePolicy: .returnCacheDataElseLoad)
|
let archiveRequest = URLRequest(url: path, cachePolicy: .returnCacheDataElseLoad)
|
||||||
var response: CachedURLResponse? = urlCache.cachedResponse(for: archiveRequest)
|
var response: CachedURLResponse? = urlCache.cachedResponse(for: archiveRequest)
|
||||||
if (response == nil) {
|
if (response == nil || response?.data.isEmpty == true) {
|
||||||
print("Downloading \(path)...")
|
print("Downloading \(path)...")
|
||||||
// download and unarchive remote directories if needed here
|
// download and unarchive remote directories if needed here
|
||||||
// use old school API to prevent deadlocks since we are running via MainActor
|
// use old school API to prevent deadlocks since we are running via MainActor
|
||||||
@@ -643,8 +670,12 @@ struct DirectoryShare {
|
|||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let (archiveData, archiveResponse) = try await URLSession.shared.data(for: archiveRequest)
|
let (archiveData, archiveResponse) = try await URLSession.shared.data(for: archiveRequest)
|
||||||
urlCache.storeCachedResponse(CachedURLResponse(response: archiveResponse, data: archiveData, storagePolicy: .allowed), for: archiveRequest)
|
if archiveData.isEmpty {
|
||||||
print("Cached for future invocations!")
|
print("Remote archive is empty!")
|
||||||
|
} else {
|
||||||
|
urlCache.storeCachedResponse(CachedURLResponse(response: archiveResponse, data: archiveData, storagePolicy: .allowed), for: archiveRequest)
|
||||||
|
print("Cached for future invocations!")
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("Download failed: \(error)")
|
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")
|
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
|
||||||
var display: VMDisplayConfig?
|
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?
|
var diskSize: UInt16?
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
|
|||||||
@@ -29,12 +29,12 @@ struct Stop: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stopRunning(_ vmDir: VMDirectory) async throws {
|
func stopRunning(_ vmDir: VMDirectory) async throws {
|
||||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
let lock = try vmDir.lock()
|
||||||
|
|
||||||
// Find the VM's PID
|
// Find the VM's PID
|
||||||
var pid = try lock.pid()
|
var pid = try lock.pid()
|
||||||
if pid == 0 {
|
if pid == 0 {
|
||||||
throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
|
throw RuntimeError.VMNotRunning(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to gracefully terminate the VM
|
// Try to gracefully terminate the VM
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ struct Suspend: AsyncParsableCommand {
|
|||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
let vmDir = try VMStorageLocal().open(name)
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
let lock = try vmDir.lock()
|
||||||
|
|
||||||
// Find the VM's PID
|
// Find the VM's PID
|
||||||
var pid = try lock.pid()
|
var pid = try lock.pid()
|
||||||
|
|||||||
@@ -41,13 +41,18 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
|
|||||||
|
|
||||||
process.waitUntilExit()
|
process.waitUntilExit()
|
||||||
|
|
||||||
|
let outputData = try outPipe.fileHandleForReading.readToEnd()
|
||||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||||
|
if let outputData = outputData {
|
||||||
|
print(String(decoding: outputData, as: UTF8.self))
|
||||||
|
}
|
||||||
throw CredentialsProviderError.Failed(message: "Docker helper failed!")
|
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(
|
let getOutput = try JSONDecoder().decode(DockerGetOutput.self, from: outputData!)
|
||||||
DockerGetOutput.self, from: outPipe.fileHandleForReading.readDataToEndOfFile()
|
|
||||||
)
|
|
||||||
return (getOutput.Username, getOutput.Secret)
|
return (getOutput.Username, getOutput.Secret)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,12 +35,12 @@ class Fetcher {
|
|||||||
//
|
//
|
||||||
// This keeps a working reference to that file, yet we don't
|
// This keeps a working reference to that file, yet we don't
|
||||||
// have to deal with the cleanup any more.
|
// have to deal with the cleanup any more.
|
||||||
let fh = try FileHandle(forReadingFrom: fileURL)
|
let mappedFile = try Data(contentsOf: fileURL, options: [.alwaysMapped])
|
||||||
try FileManager.default.removeItem(at: fileURL)
|
try FileManager.default.removeItem(at: fileURL)
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
while let data = try fh.read(upToCount: 64 * 1024 * 1024) {
|
for chunk in (0 ..< mappedFile.count).chunks(ofCount: 64 * 1024 * 1024) {
|
||||||
await dataCh.send(data)
|
await dataCh.send(mappedFile.subdata(in: chunk))
|
||||||
}
|
}
|
||||||
|
|
||||||
dataCh.finish()
|
dataCh.finish()
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
|
import SwiftRadix
|
||||||
|
|
||||||
struct Lease {
|
struct Lease {
|
||||||
var mac: MACAddress
|
var mac: MACAddress
|
||||||
var ip: IPv4Address
|
var ip: IPv4Address
|
||||||
|
var expiresAt: Date
|
||||||
|
|
||||||
init?(fromRawLease: [String : String]) {
|
init?(fromRawLease: [String : String]) {
|
||||||
// Retrieve the required fields
|
// Retrieve the required fields
|
||||||
guard let hwAddress = fromRawLease["hw_address"] else { return nil }
|
guard let hwAddress = fromRawLease["hw_address"] else { return nil }
|
||||||
guard let ipAddress = fromRawLease["ip_address"] else { return nil }
|
guard let ipAddress = fromRawLease["ip_address"] else { return nil }
|
||||||
|
guard let lease = fromRawLease["lease"] else { return nil }
|
||||||
|
|
||||||
// Parse MAC address
|
// Parse MAC address
|
||||||
let hwAddressSplits = hwAddress.split(separator: ",")
|
let hwAddressSplits = hwAddress.split(separator: ",")
|
||||||
@@ -26,7 +30,13 @@ struct Lease {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse expiration timestamp
|
||||||
|
guard let leaseTimestamp = lease.hex?.value else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
self.ip = ip
|
self.ip = ip
|
||||||
self.mac = mac
|
self.mac = mac
|
||||||
|
self.expiresAt = Date(timeIntervalSince1970: TimeInterval(leaseTimestamp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,13 +37,15 @@ class Leases {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init(_ fromString: String) throws {
|
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) }) {
|
self.leases = Dictionary(uniqueKeysWithValues: leases)
|
||||||
leases[lease.mac] = lease
|
|
||||||
}
|
|
||||||
|
|
||||||
self.leases = leases
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse leases from the host cache similarly to the PLCache_read() function found in Apple's Open Source releases.
|
/// Parse leases from the host cache similarly to the PLCache_read() function found in Apple's Open Source releases.
|
||||||
@@ -107,7 +109,7 @@ class Leases {
|
|||||||
return rawLeases
|
return rawLeases
|
||||||
}
|
}
|
||||||
|
|
||||||
func ResolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
|
func ResolveMACAddress(macAddress: MACAddress) -> IPv4Address? {
|
||||||
leases[macAddress]?.ip
|
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 {
|
class DiskV2: Disk {
|
||||||
private static let bufferSizeBytes = 4 * 1024 * 1024
|
private static let bufferSizeBytes = 4 * 1024 * 1024
|
||||||
private static let layerLimitBytes = 500 * 1000 * 1000
|
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] {
|
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, progress: Progress) async throws -> [OCIManifestLayer] {
|
||||||
var pushedLayers: [OCIManifestLayer] = []
|
var pushedLayers: [OCIManifestLayer] = []
|
||||||
|
|
||||||
// Open the disk file
|
// 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,
|
// Compress the disk file as multiple individually decompressible streams,
|
||||||
// each equal ``Self.layerLimitBytes`` bytes or slightly larger due to the
|
// each equal ``Self.layerLimitBytes`` bytes or slightly larger due to the
|
||||||
// internal compressor's buffer
|
// 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)
|
let layerDigest = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb)
|
||||||
|
|
||||||
pushedLayers.append(OCIManifestLayer(
|
pushedLayers.append(OCIManifestLayer(
|
||||||
@@ -88,14 +93,38 @@ class DiskV2: Disk {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open the disk file at the specific offset
|
// Open the disk file
|
||||||
let disk = try FileHandle(forWritingTo: diskURL)
|
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
|
// Pull and decompress a single layer into the specific offset on disk
|
||||||
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
|
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
|
||||||
if let data = data {
|
guard let data = data else {
|
||||||
disk.write(data)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +135,8 @@ class DiskV2: Disk {
|
|||||||
progress.completedUnitCount += Int64(data.count)
|
progress.completedUnitCount += Int64(data.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try filter.finalize()
|
||||||
|
|
||||||
try disk.close()
|
try disk.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,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 compressedData = Data()
|
||||||
var bytesRead: UInt64 = 0
|
var bytesRead: UInt64 = 0
|
||||||
let digest = Digest()
|
let digest = Digest()
|
||||||
@@ -126,10 +157,15 @@ class DiskV2: Disk {
|
|||||||
return nil
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let uncompressedChunk = mappedDisk.subdata(in: readFromByte ..< (readFromByte + numBytesToRead))
|
||||||
|
|
||||||
bytesRead += UInt64(uncompressedChunk.count)
|
bytesRead += UInt64(uncompressedChunk.count)
|
||||||
digest.update(uncompressedChunk)
|
digest.update(uncompressedChunk)
|
||||||
|
|
||||||
|
|||||||
@@ -102,8 +102,7 @@ class Registry {
|
|||||||
private let baseURL: URL
|
private let baseURL: URL
|
||||||
let namespace: String
|
let namespace: String
|
||||||
let credentialsProviders: [CredentialsProvider]
|
let credentialsProviders: [CredentialsProvider]
|
||||||
|
let authenticationKeeper = AuthenticationKeeper()
|
||||||
var currentAuthToken: Authentication? = nil
|
|
||||||
|
|
||||||
var host: String? {
|
var host: String? {
|
||||||
guard let host = baseURL.host else { return nil }
|
guard let host = baseURL.host else { return nil }
|
||||||
@@ -253,7 +252,7 @@ class Registry {
|
|||||||
for try await part in channel {
|
for try await part in channel {
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
|
|
||||||
try await handler(Data(part))
|
try await handler(part)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,11 +304,6 @@ class Registry {
|
|||||||
request.httpBody = body
|
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)
|
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
|
||||||
|
|
||||||
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
|
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
|
||||||
@@ -331,7 +325,7 @@ class Registry {
|
|||||||
|
|
||||||
if wwwAuthenticate.scheme.lowercased() == "basic" {
|
if wwwAuthenticate.scheme.lowercased() == "basic" {
|
||||||
if let (user, password) = try lookupCredentials() {
|
if let (user, password) = try lookupCredentials() {
|
||||||
currentAuthToken = BasicAuthentication(user: user, password: password)
|
await authenticationKeeper.set(BasicAuthentication(user: user, password: password))
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -378,7 +372,7 @@ class Registry {
|
|||||||
+ "while retrieving an authentication token", details: data.asText())
|
+ "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)? {
|
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) {
|
private func authAwareRequest(request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||||
var request = request
|
var request = request
|
||||||
|
|
||||||
if let token = currentAuthToken {
|
if let (name, value) = await authenticationKeeper.header() {
|
||||||
let (name, value) = token.header()
|
|
||||||
request.addValue(value, forHTTPHeaderField: name)
|
request.addValue(value, forHTTPHeaderField: name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ struct Darwin: PlatformSuspendable {
|
|||||||
let result = VZMacPlatformConfiguration()
|
let result = VZMacPlatformConfiguration()
|
||||||
|
|
||||||
result.machineIdentifier = ecid
|
result.machineIdentifier = ecid
|
||||||
result.auxiliaryStorage = VZMacAuxiliaryStorage(contentsOf: nvramURL)
|
result.auxiliaryStorage = VZMacAuxiliaryStorage(url: nvramURL)
|
||||||
|
|
||||||
if !hardwareModel.isSupported {
|
if !hardwareModel.isSupported {
|
||||||
// At the moment support of M1 chip is not yet dropped in any macOS version
|
// At the moment support of M1 chip is not yet dropped in any macOS version
|
||||||
|
|||||||
@@ -326,9 +326,18 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Storage
|
// Storage
|
||||||
var devices: [VZStorageDeviceConfiguration] = [
|
let attachment: VZDiskImageStorageDeviceAttachment = vmConfig.os == .linux ?
|
||||||
VZVirtioBlockDeviceConfiguration(attachment: try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false))
|
// 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)
|
devices.append(contentsOf: additionalStorageDevices)
|
||||||
configuration.storageDevices = devices
|
configuration.storageDevices = devices
|
||||||
|
|
||||||
|
|||||||
@@ -132,20 +132,30 @@ struct VMConfig: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mutating func setCPU(cpuCount: Int) throws {
|
mutating func setCPU(cpuCount: Int) throws {
|
||||||
if cpuCount < cpuCountMin {
|
if os == .darwin && cpuCount < cpuCountMin {
|
||||||
throw LessThanMinimalResourcesError("VM should have \(cpuCountMin) CPU cores"
|
throw LessThanMinimalResourcesError("VM should have \(cpuCountMin) CPU cores"
|
||||||
+ " at minimum (requested \(cpuCount))")
|
+ " at minimum (requested \(cpuCount))")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cpuCount < VZVirtualMachineConfiguration.minimumAllowedCPUCount {
|
||||||
|
throw LessThanMinimalResourcesError("VM should have \(VZVirtualMachineConfiguration.minimumAllowedCPUCount) CPU cores"
|
||||||
|
+ " at minimum (requested \(cpuCount))")
|
||||||
|
}
|
||||||
|
|
||||||
self.cpuCount = cpuCount
|
self.cpuCount = cpuCount
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func setMemory(memorySize: UInt64) throws {
|
mutating func setMemory(memorySize: UInt64) throws {
|
||||||
if memorySize < memorySizeMin {
|
if os == .darwin && memorySize < memorySizeMin {
|
||||||
throw LessThanMinimalResourcesError("VM should have \(memorySizeMin) bytes"
|
throw LessThanMinimalResourcesError("VM should have \(memorySizeMin) bytes"
|
||||||
+ " of memory at minimum (requested \(memorySize))")
|
+ " 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
|
self.memorySize = memorySize
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,13 +30,17 @@ struct VMDirectory: Prunable {
|
|||||||
baseURL
|
baseURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lock() throws -> PIDLock {
|
||||||
|
try PIDLock(lockURL: configURL)
|
||||||
|
}
|
||||||
|
|
||||||
func running() throws -> Bool {
|
func running() throws -> Bool {
|
||||||
// The most common reason why PIDLock() instantiation fails is a race with "tart delete" (ENOENT),
|
// The most common reason why PIDLock() instantiation fails is a race with "tart delete" (ENOENT),
|
||||||
// which is fine to report as "not running".
|
// 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 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.
|
// 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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,14 +134,31 @@ struct VMDirectory: Prunable {
|
|||||||
if !FileManager.default.fileExists(atPath: diskURL.path) {
|
if !FileManager.default.fileExists(atPath: diskURL.path) {
|
||||||
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
|
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
let diskFileHandle = try FileHandle.init(forWritingTo: diskURL)
|
let diskFileHandle = try FileHandle.init(forWritingTo: diskURL)
|
||||||
// macOS considers kilo being 1000 and not 1024
|
let currentDiskFileLength = try diskFileHandle.seekToEnd()
|
||||||
try diskFileHandle.truncate(atOffset: UInt64(sizeGB) * 1000 * 1000 * 1000)
|
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()
|
try diskFileHandle.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func delete() throws {
|
func delete() throws {
|
||||||
|
let lock = try lock()
|
||||||
|
|
||||||
|
if try !lock.trylock() {
|
||||||
|
throw RuntimeError.VMIsRunning(name)
|
||||||
|
}
|
||||||
|
|
||||||
try FileManager.default.removeItem(at: baseURL)
|
try FileManager.default.removeItem(at: baseURL)
|
||||||
|
|
||||||
|
try lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func accessDate() throws -> Date {
|
func accessDate() throws -> Date {
|
||||||
|
|||||||
@@ -50,10 +50,12 @@ enum RuntimeError : Error {
|
|||||||
case VMConfigurationError(_ message: String)
|
case VMConfigurationError(_ message: String)
|
||||||
case VMDoesNotExist(name: String)
|
case VMDoesNotExist(name: String)
|
||||||
case VMMissingFiles(_ message: String)
|
case VMMissingFiles(_ message: String)
|
||||||
case VMNotRunning(_ message: String)
|
case VMIsRunning(_ name: String)
|
||||||
|
case VMNotRunning(_ name: String)
|
||||||
case VMAlreadyRunning(_ message: String)
|
case VMAlreadyRunning(_ message: String)
|
||||||
case NoIPAddressFound(_ message: String)
|
case NoIPAddressFound(_ message: String)
|
||||||
case DiskAlreadyInUse(_ message: String)
|
case DiskAlreadyInUse(_ message: String)
|
||||||
|
case InvalidDiskSize(_ message: String)
|
||||||
case FailedToUpdateAccessDate(_ message: String)
|
case FailedToUpdateAccessDate(_ message: String)
|
||||||
case PIDLockFailed(_ message: String)
|
case PIDLockFailed(_ message: String)
|
||||||
case FailedToParseRemoteName(_ message: String)
|
case FailedToParseRemoteName(_ message: String)
|
||||||
@@ -81,14 +83,18 @@ extension RuntimeError : CustomStringConvertible {
|
|||||||
return "the specified VM \"\(name)\" does not exist"
|
return "the specified VM \"\(name)\" does not exist"
|
||||||
case .VMMissingFiles(let message):
|
case .VMMissingFiles(let message):
|
||||||
return message
|
return message
|
||||||
case .VMNotRunning(let message):
|
case .VMIsRunning(let name):
|
||||||
return message
|
return "VM \"\(name)\" is running"
|
||||||
|
case .VMNotRunning(let name):
|
||||||
|
return "VM \"\(name)\" is not running"
|
||||||
case .VMAlreadyRunning(let message):
|
case .VMAlreadyRunning(let message):
|
||||||
return message
|
return message
|
||||||
case .NoIPAddressFound(let message):
|
case .NoIPAddressFound(let message):
|
||||||
return message
|
return message
|
||||||
case .DiskAlreadyInUse(let message):
|
case .DiskAlreadyInUse(let message):
|
||||||
return message
|
return message
|
||||||
|
case .InvalidDiskSize(let message):
|
||||||
|
return message
|
||||||
case .FailedToUpdateAccessDate(let message):
|
case .FailedToUpdateAccessDate(let message):
|
||||||
return message
|
return message
|
||||||
case .PIDLockFailed(let message):
|
case .PIDLockFailed(let message):
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class VMStorageLocal: PrunableStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func delete(_ name: String) throws {
|
func delete(_ name: String) throws {
|
||||||
try FileManager.default.removeItem(at: vmURL(name))
|
try VMDirectory(baseURL: vmURL(name)).delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
func list() throws -> [(String, VMDirectory)] {
|
func list() throws -> [(String, VMDirectory)] {
|
||||||
|
|||||||
@@ -219,9 +219,7 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func link(from: RemoteName, to: RemoteName) throws {
|
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))
|
try FileManager.default.createSymbolicLink(at: vmURL(from), withDestinationURL: vmURL(to))
|
||||||
|
|
||||||
|
|||||||
@@ -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,38 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,12 @@ final class MACAddressResolverTests: XCTestCase {
|
|||||||
{
|
{
|
||||||
ip_address=1.2.3.4
|
ip_address=1.2.3.4
|
||||||
hw_address=1,00:11:22:33:44:55
|
hw_address=1,00:11:22:33:44:55
|
||||||
|
lease=0x7fffffff
|
||||||
}
|
}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
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 {
|
func testMultipleEntries() throws {
|
||||||
@@ -20,16 +21,18 @@ final class MACAddressResolverTests: XCTestCase {
|
|||||||
{
|
{
|
||||||
ip_address=1.2.3.4
|
ip_address=1.2.3.4
|
||||||
hw_address=1,00:11:22:33:44:55
|
hw_address=1,00:11:22:33:44:55
|
||||||
|
lease=0x7fffffff
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
ip_address=5.6.7.8
|
ip_address=5.6.7.8
|
||||||
hw_address=1,AA:BB:CC:DD:EE:FF
|
hw_address=1,AA:BB:CC:DD:EE:FF
|
||||||
|
lease=0x7fffffff
|
||||||
}
|
}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
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"),
|
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: 2.8 MiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 538 KiB |
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
draft: false
|
||||||
|
date: 2023-10-06
|
||||||
|
search:
|
||||||
|
exclude: true
|
||||||
|
authors:
|
||||||
|
- fkorotkov
|
||||||
|
categories:
|
||||||
|
- announcement
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tart is now available on AWS Marketplace
|
||||||
|
|
||||||
|
Announcing [official AMIs for EC2 Mac Instances](https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws)
|
||||||
|
with preconfigured Tart installation that is optimized to work within AWS infrastructure.
|
||||||
|
|
||||||
|
EC2 Mac Instances is a gem of engineering powered by AWS Nitro devices. Just imagine there is a physical Mac Mini with
|
||||||
|
a plugged in Nitro device that can push the physical power button!
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
This clever synergy between Apple Hardware and Nitro System allows seamless integration with VPC networking and booting macOS from an EBS volume.
|
||||||
|
|
||||||
|
In this blog post we’ll see how a virtualization solution like Tart can compliment and elevate experience with EC2 Mac Instances.
|
||||||
|
|
||||||
|
<!-- more -->
|
||||||
|
|
||||||
|
Let’s start from the basics, what EC2 Mac Instances allow to do compared to physical Mac Minis seating in offices of
|
||||||
|
many companies around the world?
|
||||||
|
|
||||||
|
First and foremost, EC2 Mac Instances sit inside AWS data centers and can leverage all the goodies of VPC networking
|
||||||
|
within your company's existing infrastructure. No need to connect your Macs in the office through a VPN and deal
|
||||||
|
with networking and security.
|
||||||
|
|
||||||
|
Additionally, EC2 Mac Instances are booting from EBS volumes which means it is possible to always have reproducible instances
|
||||||
|
and apply all the best practices of Infrastructure-as-Code. Managing a fleet of physical Macs is a pain and it's very hard
|
||||||
|
to make them configured in a reproducible and stable way. With booting from identical EBS volumes your team is always sure
|
||||||
|
about the identical initial state of the fleet.
|
||||||
|
|
||||||
|
## Compromises of EC2 Mac Instances
|
||||||
|
|
||||||
|
The flexibility of EBS volumes for macOS comes with some compromises that virtualization solutions like Tart can help with.
|
||||||
|
The initial boot from an EBS volume takes some time and not instant. macOS itself is pretty heavy and a Nitro device needs
|
||||||
|
to download tens of gigabytes that macOS requires in order to boot. This means that **resetting a EC2 Mac Instance to a clean state
|
||||||
|
is not instant and usually takes a couple of minutes** when you can’t utilize the precious resources for your workloads.
|
||||||
|
|
||||||
|
It is much easier to tailor such EBS volumes with tools like Packer but there is still a **friction to test newly created EBS volumes**
|
||||||
|
since one needs to start and run a EC2 Mac Instance and it’s not possible to test things locally. Similarly it is even harder
|
||||||
|
to test beta versions of macOS that require manual interaction with a running instance.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Tart can help with all the compromises! Tart virtual machines (VMs) have nearly native performance thanks to utilizing
|
||||||
|
native `Virtualization.Framework` that was developed along the first Apple Silicon chip. **Tart VMs can be copied/disposed
|
||||||
|
instantly and booting a fresh Tart VM takes only several seconds**. It is also possible to run two different Tart VMs in parallel
|
||||||
|
that can have completely different versions of macOS and packages. For example, it is possible to have the latest stable macOS
|
||||||
|
with the release version of Xcode along with the next version of macOS with the latest beta of Xcode.
|
||||||
|
|
||||||
|
Creation of Tart VMs can be automated with [a Packer plugin](https://github.com/cirruslabs/packer-plugin-tart) the same way as
|
||||||
|
creation of EC2 AMIs with one caveat that **Tart Packer Plugin works locally so you can test the same virtual machine locally
|
||||||
|
as you would run it in the cloud**.
|
||||||
|
|
||||||
|
Lightweight nature of Tart VMs with a focus on an easy-to-integrate Tart CLI compliments any macOS automation and helps to reduce
|
||||||
|
the feedback cycle and improves reproducibility of macOS environments even further.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
We are excited to bring [official AMIs that include Tart installation optimized to work within AWS](https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws).
|
||||||
|
In the coming weeks when macOS Sonoma will become available on AWS we’ll release another update specifically targeting EC2 Mac Instances.
|
||||||
|
This update will simplify access to local SSDs of Mac Instances that are slightly faster than EBS volumes. Stay tuned and don’t hesitate
|
||||||
|
to ask any [questions](https://tart.run/licensing/).
|
||||||
@@ -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.
|
||||||
@@ -66,3 +66,25 @@ 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.
|
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.
|
||||||
|
|||||||
@@ -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,7 +1,7 @@
|
|||||||
# Cirrus Runners for GitHub Actions
|
# 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.
|
*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, compared to GitHub's own macOS runners with just 3 cores and only supporting the outdated Apple–Intel architecture.
|
Your actions will be executed in clean macOS virtual machines with 4 Apple M2 cores.
|
||||||
|
|
||||||
## Testimonials from customers
|
## Testimonials from customers
|
||||||
|
|
||||||
@@ -24,6 +24,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)
|
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).
|
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
|
### Priority Support
|
||||||
|
|
||||||
Subscriptions of 20 or more Cirrus Runners include access to [Priority Support](../licensing.md#priority-support).
|
Subscriptions of 20 or more Cirrus Runners include access to [Priority Support](../licensing.md#priority-support).
|
||||||
@@ -84,6 +93,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
|
## 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
|
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]]
|
[[runners]]
|
||||||
# ...
|
# ...
|
||||||
executor = "custom"
|
executor = "custom"
|
||||||
builds_dir = "/Users/admin/builds" # directory inside the
|
builds_dir = "/Users/admin/builds" # directory inside the VM
|
||||||
cache_dir = "/Users/admin/cache"
|
cache_dir = "/Users/admin/cache"
|
||||||
[runners.feature_flags]
|
[runners.feature_flags]
|
||||||
FF_RESOLVE_FULL_TLS_CHAIN = false
|
FF_RESOLVE_FULL_TLS_CHAIN = false
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ If your organization is interested in purchasing one of the license tiers, pleas
|
|||||||
|
|
||||||
You can see a template of a license subscription agreement [here](assets/TartLicenseSubscription.pdf).
|
You can see a template of a license subscription agreement [here](assets/TartLicenseSubscription.pdf).
|
||||||
|
|
||||||
|
!!! info "Running on AWS?"
|
||||||
|
|
||||||
|
There are [official AMIs for EC2 Mac Instances](https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws)
|
||||||
|
with preconfigured Tart installation that is optimized to work within AWS infrastructure.
|
||||||
|
|
||||||
|
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
|
# 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).
|
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).
|
||||||
|
|||||||
@@ -28,6 +28,38 @@ tart run sonoma-base
|
|||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartScreenshot.png"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartScreenshot.png"/>
|
||||||
</p>
|
</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`
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## SSH access
|
## SSH access
|
||||||
|
|
||||||
If the guest VM is running and configured to accept incoming SSH connections you can conveniently connect to it like so:
|
If the guest VM is running and configured to accept incoming SSH connections you can conveniently connect to it like so:
|
||||||
@@ -41,7 +73,7 @@ ssh admin@$(tart ip sonoma-base)
|
|||||||
from within Tart virtual machines. Alternatively, you can use plain ssh connection and `tart ip` command:
|
from within Tart virtual machines. Alternatively, you can use plain ssh connection and `tart ip` command:
|
||||||
|
|
||||||
```bash
|
```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) "uname -a"
|
||||||
sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip sonoma-base) < script.sh
|
sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip sonoma-base) < script.sh
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block announce %}
|
|
||||||
<a href="/blog/2023/09/20/tart-200-and-community-updates/">
|
|
||||||
🚀🚀🚀  Announcing <strong>Tart 2.0.0</strong> 🚀🚀🚀
|
|
||||||
</a>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
<!-- Render landing page under tabs -->
|
<!-- Render landing page under tabs -->
|
||||||
{% block tabs %} {{ super() }}
|
{% block tabs %} {{ super() }}
|
||||||
|
|
||||||
@@ -242,12 +236,15 @@
|
|||||||
}
|
}
|
||||||
let counterElement = document.getElementById('installation-counter');
|
let counterElement = document.getElementById('installation-counter');
|
||||||
if (counterElement) {
|
if (counterElement) {
|
||||||
counterElement.textContent = Math.round(allDownloads / 1000) + ",000"
|
// Live installation count is available starting version 1.0.0
|
||||||
|
// Prior Tart was installed a little over 14,000 times, let's count them too
|
||||||
|
let installationPriorV1 = 14
|
||||||
|
counterElement.textContent = (installationPriorV1 + Math.round(allDownloads / 1000)) + ",000"
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<h2>
|
<h2>
|
||||||
With more than <strong id="installation-counter">10,000</strong> installations to date, Tart has been adopted for various scenarios.
|
With more than <strong id="installation-counter">25,000</strong> installations to date, Tart has been adopted for various scenarios.
|
||||||
Its applications range from powering CI/CD pipelines and reproducible local development environments,
|
Its applications range from powering CI/CD pipelines and reproducible local development environments,
|
||||||
to helping in the testing of device management systems without actual physical devices.
|
to helping in the testing of device management systems without actual physical devices.
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ nav:
|
|||||||
- "Integrations":
|
- "Integrations":
|
||||||
- "GitHub Actions": integrations/github-actions.md
|
- "GitHub Actions": integrations/github-actions.md
|
||||||
- "GitLab Runner": integrations/gitlab-runner.md
|
- "GitLab Runner": integrations/gitlab-runner.md
|
||||||
|
- "Buildkite": integrations/buildkite.md
|
||||||
- "Self-hosted CI": integrations/cirrus-cli.md
|
- "Self-hosted CI": integrations/cirrus-cli.md
|
||||||
- "Managing VMs": integrations/vm-management.md
|
- "Managing VMs": integrations/vm-management.md
|
||||||
- "Support & Licensing": licensing.md
|
- "Support & Licensing": licensing.md
|
||||||
|
|||||||
@@ -8,4 +8,8 @@ set -e
|
|||||||
swift build --product tart
|
swift build --product tart
|
||||||
codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/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 "$@"
|
||||||
|
|||||||