Compare commits

...
24 Commits
Author SHA1 Message Date
Nikolay Edigaryev 537f0ae5db OCI storage: unconditionally remove the old link when link()'ing (#686) 2023-12-08 17:20:49 +04:00
Fedor Korotkov 02f1ff5238 Fixed image url 2023-12-08 03:23:54 -05:00
Fedor Korotkov 9c9bcd586e Highlight AWS Marketplace availability (#683)
* Highlight AWS Marketplace availability

* Updated image

* Changed height
2023-12-08 10:44:41 +04:00
Fedor Korotkov 60f0eac7a8 Cache only non-empty archives (#685)
Fixes #684. But I'm not sure how it got into this state in the first place. `URLSession.shared.data` should've throw.
2023-12-08 10:43:43 +04:00
Andrew Malchuk 35377a3475 Fix the filesystem corruption on Linux VMs (#675)
* Use NVMe drive, cached mode and full synchronization mode on Linux

* Inline getting storage device attachment
2023-12-01 15:56:58 +00:00
Nikolay Edigaryev dda4e91a91 Document Buildkite Tart Plugin (#677)
* Document Buildkite Tart Plugin

* Fix cropped screenshot
2023-12-01 15:35:45 +00:00
Nikolay Edigaryev ac5f794e6d tart delete: prevent the deletion of running VMs (#676)
And introduce a VMDirectory.lock() method to avoid duplication of
the PIDLock(lockURL: vmDir.configURL) snippet.
2023-12-01 09:33:01 -05:00
Nikolay Edigaryev 5bcbc77249 Document available VM images on the website (#674)
* Document available VM images on the website

* Fix indents
2023-11-28 16:31:39 +00:00
Fedor Korotkov bf03873c8d Validate that a disk is not amd64 (#673)
To improve UX for cases like #672
2023-11-28 14:55:34 +00:00
Nikolay Edigaryev bad37b129c DiskV2: write layers sparsely to avoid unnecessary disk usage (#671) 2023-11-27 23:27:07 +04:00
Nikolay Edigaryev 0f47cca746 MAC address resolver: skip expired leases (#669) 2023-11-27 10:12:36 -05:00
Fedor Korotkov d70eca4484 Document Cirrus Runners Discounts (#663) 2023-11-22 20:00:45 +04:00
Fedor Korotkov 25887b075f Use ssh from our tap (#662)
Fixes #661
2023-11-20 20:01:57 +00:00
Simon B. Støvring 8c011623be Adds Shape logo to README (#658) 2023-11-15 14:51:10 +00:00
Fedor Korotkov 2dccdfb306 Document ECR Public Mirror (#656)
Fixes https://github.com/cirruslabs/tart/discussions/652
2023-11-13 18:18:51 +00:00
Fedor KorotkovandNikolay Edigaryev aca768a838 Print put errors from Docker Helpers (#654)
* Print put errors from Docker Helpers

* Update Sources/tart/Credentials/DockerConfigCredentialsProvider.swift

Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>

* Check output data is not empty

---------

Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>
2023-11-10 22:44:51 +04:00
Tor Arne Vestbø 68b3557747 Hide dock icon in no graphics mode (#653)
* Package tart binary into app bundle when running via run-signed.sh

This is what happens when installing the tart application package
as built by CI. We should stay as close as possible to the install
situation during development, so that we get bug/behavior parity.

For example, an app bundle behaves differently than a standalone
executable when it comes to bringing up a Dock icon for the app.

* Set activation policy to prohibited when starting in no graphics mode

This ensures that the Dock icon is hidden.
2023-11-10 09:05:20 -05:00
Fedor Korotkov b2c923f2fe Properly enter main even loop in headless mode (#651)
Fixes #638
2023-11-09 00:05:00 +04:00
Fedor Korotkov c75009e46f Introduce --capture-system-keys flag (#650)
To allow guest to capture things like Cmd+Tab.

Fixes #636
2023-11-08 20:21:55 +04:00
Riain Condon 43e74ab769 fix docs to specify inside VM for gitlab runner (#649)
just adds specifically VM in the gitlab runner docs to avoid confusion of where the build and cache dirs are
2023-11-08 09:05:19 -05:00
Fedor Korotkov 1338864ed6 Don't install Sentry CLI via brew (#648)
Seems it installas 1.x version instead of 2.x. Sentry's documentation [recommends to use their script](https://docs.sentry.io/product/cli/installation/?original_referrer=https%3A%2F%2Fwww.google.com%2F#automatic-installation).
2023-11-07 16:23:20 +00:00
Nikolay Edigaryev 70040b633c Introduce AuthenticationKeeper actor to serialize authn modification (#647) 2023-11-06 14:58:23 -05:00
Nikolay Edigaryev f4bc02d175 DiskV2.push(): map disk into memory to avoid large allocations (#645) 2023-11-03 17:13:10 +04:00
Fedor Korotkov 6c24aa639a [blog] New dashboard with insights into performance of Cirrus Runners (#644) 2023-11-03 12:17:40 +04:00
36 changed files with 394 additions and 70 deletions
+2 -1
View File
@@ -83,8 +83,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
+9
View File
@@ -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",
+2
View File
@@ -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",
+9
View File
@@ -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>
<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 +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).
<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):
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

+34 -12
View File
@@ -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\"."))
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 {
if vnc && vncExperimental {
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
@@ -121,6 +125,10 @@ struct Run: AsyncParsableCommand {
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 vmDir = try localStorage.open(name)
if try vmDir.state() == "suspended" {
@@ -132,6 +140,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
@@ -210,7 +224,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!")
}
@@ -304,9 +318,13 @@ struct Run: AsyncParsableCommand {
let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics
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 {
runUI(suspendable)
runUI(suspendable, captureSystemKeys)
}
}
@@ -467,7 +485,7 @@ struct Run: AsyncParsableCommand {
return [device]
}
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 +494,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 +551,7 @@ struct Run: AsyncParsableCommand {
}
MainApp.disappearSignal = suspendable ? SIGUSR1 : SIGINT
MainApp.capturesSystemKeys = captureSystemKeys
MainApp.main()
}
}
@@ -581,14 +601,12 @@ 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
@@ -641,7 +659,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 +667,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)")
}
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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))
}
}
+9 -7
View File
@@ -37,13 +37,15 @@ 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 = leases
self.leases = Dictionary(uniqueKeysWithValues: leases)
}
/// 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
}
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
}
}
+42 -8
View File
@@ -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)
+4 -11
View File
@@ -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)
}
+12 -3
View File
@@ -326,9 +326,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
+13 -1
View File
@@ -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
}
@@ -137,7 +141,15 @@ struct VMDirectory: Prunable {
}
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 {
+6 -3
View File
@@ -50,7 +50,8 @@ 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)
@@ -81,8 +82,10 @@ 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):
+1 -1
View File
@@ -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)] {
+1 -3
View File
@@ -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))
+18
View File
@@ -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")
}
}
+38
View File
@@ -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
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")!))
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

@@ -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**.
![Cirrus Runners Dashboard](/blog/images/runners-price-performance-2.png)
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**.
![Cirrus Runners Dashboard](/blog/images/runners-price-performance-3.png)
## 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.
+21
View File
@@ -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):
![](/assets/images/BuildkiteTartPlugin.png)
## 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.
+19 -2
View File
@@ -1,7 +1,7 @@
# 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
@@ -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)
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 +93,14 @@ Note that Cirrus Runners will get added to the default runner group.
![](/assets/images/TartGHARunners.png)
### 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.
![](/assets/images/RunnersDashboard.png)
## 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
+1 -1
View File
@@ -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
+3
View File
@@ -61,6 +61,9 @@ 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.
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).
+33 -1
View File
@@ -28,6 +28,38 @@ 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`
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
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:
```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
```
-6
View File
@@ -1,11 +1,5 @@
{% extends "base.html" %}
{% block announce %}
<a href="/blog/2023/10/06/tart-is-now-available-on-aws-marketplace/">
☁️☁️☁️&nbsp&nbspTart is now available on <strong>AWS Marketplace</strong>&nbsp;&nbsp;☁️☁️☁️
</a>
{% endblock %}
<!-- Render landing page under tabs -->
{% block tabs %} {{ super() }}
+1
View File
@@ -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
+5 -1
View File
@@ -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 "$@"