From e443cfa9a2d8d3759aa13602a4581f4d1e6c7da5 Mon Sep 17 00:00:00 2001 From: Nikolay Edigaryev Date: Fri, 12 Sep 2025 17:17:17 +0200 Subject: [PATCH 01/20] tart exec: do not attempt to call TTY-related methods when no -t is set (#1122) --- Sources/tart/Commands/Exec.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Sources/tart/Commands/Exec.swift b/Sources/tart/Commands/Exec.swift index bfcc309..20952c9 100644 --- a/Sources/tart/Commands/Exec.swift +++ b/Sources/tart/Commands/Exec.swift @@ -87,11 +87,13 @@ struct Exec: AsyncParsableCommand { $0.args = Array(command.dropFirst(1)) $0.interactive = interactive $0.tty = tty - $0.terminalSize = .with { - let (width, height) = try! Term.GetSize() + if tty { + $0.terminalSize = .with { + let (width, height) = try! Term.GetSize() - $0.cols = UInt32(width) - $0.rows = UInt32(height) + $0.cols = UInt32(width) + $0.rows = UInt32(height) + } } }) }) From b78fa6ba1caa88454039e97a84883eef9e1f6e1e Mon Sep 17 00:00:00 2001 From: Nikolay Edigaryev Date: Sun, 14 Sep 2025 15:40:06 +0200 Subject: [PATCH 02/20] ASIF is available only starting from macOS 26 (Tahoe) (#1096) * ASIF is available only starting from macOS 26 (Tahoe) * Remove testRawFormatIsAlwaysSupported() test * Fix testASIFFormatSupport() test to check for macOS 26+ --- Sources/tart/DiskImageFormat.swift | 2 +- Tests/TartTests/DiskImageFormatTests.swift | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/Sources/tart/DiskImageFormat.swift b/Sources/tart/DiskImageFormat.swift index 36b6641..7ebd268 100644 --- a/Sources/tart/DiskImageFormat.swift +++ b/Sources/tart/DiskImageFormat.swift @@ -21,7 +21,7 @@ enum DiskImageFormat: String, CaseIterable, Codable { case .raw: return true case .asif: - if #available(macOS 15, *) { + if #available(macOS 26, *) { return true } else { return false diff --git a/Tests/TartTests/DiskImageFormatTests.swift b/Tests/TartTests/DiskImageFormatTests.swift index bc78a4d..a4e59a9 100644 --- a/Tests/TartTests/DiskImageFormatTests.swift +++ b/Tests/TartTests/DiskImageFormatTests.swift @@ -2,13 +2,9 @@ import XCTest @testable import tart final class DiskImageFormatTests: XCTestCase { - func testRawFormatIsAlwaysSupported() throws { - XCTAssertTrue(DiskImageFormat.raw.isSupported) - } - func testASIFFormatSupport() throws { - // ASIF should be supported on macOS 15+ - if #available(macOS 15, *) { + // ASIF should be supported on macOS 26+ + if #available(macOS 26, *) { XCTAssertTrue(DiskImageFormat.asif.isSupported) } else { XCTAssertFalse(DiskImageFormat.asif.isSupported) From 96c89ad76e49a7b4f6a56155a2095f5c75ce1380 Mon Sep 17 00:00:00 2001 From: Fedor Korotkov Date: Sun, 14 Sep 2025 09:40:58 -0400 Subject: [PATCH 03/20] tart clone: cap automatic pruning at 100 GB (#1124) --- Sources/tart/Commands/Clone.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/tart/Commands/Clone.swift b/Sources/tart/Commands/Clone.swift index d09fae2..a6064c6 100644 --- a/Sources/tart/Commands/Clone.swift +++ b/Sources/tart/Commands/Clone.swift @@ -76,8 +76,10 @@ struct Clone: AsyncParsableCommand { // // So, once we clone the VM let's try to claim the rest of space for the VM to run without errors. let unallocatedBytes = try sourceVM.sizeBytes() - sourceVM.allocatedSizeBytes() - if unallocatedBytes > 0 { - try Prune.reclaimIfNeeded(UInt64(unallocatedBytes), sourceVM) + // Avoid reclaiming an excessive amount of disk space. + let reclaimBytes = min(unallocatedBytes, 100 * 1024 * 1024 * 1024) + if reclaimBytes > 0 { + try Prune.reclaimIfNeeded(UInt64(reclaimBytes), sourceVM) } }, onCancel: { try? FileManager.default.removeItem(at: tmpVMDir.baseURL) From 02bf5651e7f59d576706a75901537b42e333bb06 Mon Sep 17 00:00:00 2001 From: Fedor Korotkov Date: Sun, 14 Sep 2025 12:38:57 -0400 Subject: [PATCH 04/20] tart clone: make pruning limit configurable (#1126) * tart clone: make pruning limit configurable * Fixed compilation --- Sources/tart/Commands/Clone.swift | 5 ++++- docs/faq.md | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/tart/Commands/Clone.swift b/Sources/tart/Commands/Clone.swift index a6064c6..0b38850 100644 --- a/Sources/tart/Commands/Clone.swift +++ b/Sources/tart/Commands/Clone.swift @@ -31,6 +31,9 @@ struct Clone: AsyncParsableCommand { @Flag(help: .hidden) var deduplicate: Bool = false + @Option(help: ArgumentHelp("limit automatic pruning to n gigabytes", valueName: "n")) + var pruneLimit: UInt = 100 + func validate() throws { if newName.contains("/") { throw ValidationError(" should be a local name") @@ -77,7 +80,7 @@ struct Clone: AsyncParsableCommand { // So, once we clone the VM let's try to claim the rest of space for the VM to run without errors. let unallocatedBytes = try sourceVM.sizeBytes() - sourceVM.allocatedSizeBytes() // Avoid reclaiming an excessive amount of disk space. - let reclaimBytes = min(unallocatedBytes, 100 * 1024 * 1024 * 1024) + let reclaimBytes = min(unallocatedBytes, Int(pruneLimit) * 1024 * 1024 * 1024) if reclaimBytes > 0 { try Prune.reclaimIfNeeded(UInt64(reclaimBytes), sourceVM) } diff --git a/docs/faq.md b/docs/faq.md index 0ef4dd6..a4a9dfe 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -166,6 +166,8 @@ Tart does have an analogue of Anka Controller for managing VMs across a cluster In case there's not enough space to fit the newly pulled or cloned VM image, Tart will remove the least recently accessed VMs from OCI cache and `.ipsw` files from IPSW cache until enough free space is available. +The `tart clone` command limits this automatic pruning to 100 GB by default to avoid removing too many cached items. You can change this limit with the `--prune-limit` option (in gigabytes). + To disable this functionality, set the `TART_NO_AUTO_PRUNE` environment variable either globally: ```shell From df100f1ca2a52ea287d38f9cab5bd38acec04497 Mon Sep 17 00:00:00 2001 From: Nikolay Edigaryev Date: Mon, 22 Sep 2025 20:57:05 +0200 Subject: [PATCH 05/20] Improve credential provider errors (#1133) --- Sources/tart/Commands/Login.swift | 2 ++ Sources/tart/Credentials/CredentialsProvider.swift | 1 + .../Credentials/DockerConfigCredentialsProvider.swift | 2 ++ .../tart/Credentials/EnvironmentCredentialsProvider.swift | 2 ++ .../tart/Credentials/KeychainCredentialsProvider.swift | 2 ++ Sources/tart/Credentials/StdinCredentials.swift | 2 ++ Sources/tart/OCI/Registry.swift | 8 ++++++-- 7 files changed, 17 insertions(+), 2 deletions(-) diff --git a/Sources/tart/Commands/Login.swift b/Sources/tart/Commands/Login.swift index 2dc37e3..230851f 100644 --- a/Sources/tart/Commands/Login.swift +++ b/Sources/tart/Commands/Login.swift @@ -64,6 +64,8 @@ struct Login: AsyncParsableCommand { } fileprivate class DictionaryCredentialsProvider: CredentialsProvider { + let userFriendlyName = "static dictionary credentials provider" + var credentials: Dictionary init(_ credentials: Dictionary) { diff --git a/Sources/tart/Credentials/CredentialsProvider.swift b/Sources/tart/Credentials/CredentialsProvider.swift index 5da19bc..a874fa1 100644 --- a/Sources/tart/Credentials/CredentialsProvider.swift +++ b/Sources/tart/Credentials/CredentialsProvider.swift @@ -5,6 +5,7 @@ enum CredentialsProviderError: Error { } protocol CredentialsProvider { + var userFriendlyName: String { get } func retrieve(host: String) throws -> (String, String)? func store(host: String, user: String, password: String) throws } diff --git a/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift b/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift index 6be901f..f4e270c 100644 --- a/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift +++ b/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift @@ -1,6 +1,8 @@ import Foundation class DockerConfigCredentialsProvider: CredentialsProvider { + let userFriendlyName = "Docker configuration credentials provider" + func retrieve(host: String) throws -> (String, String)? { let dockerConfigURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json") if !FileManager.default.fileExists(atPath: dockerConfigURL.path) { diff --git a/Sources/tart/Credentials/EnvironmentCredentialsProvider.swift b/Sources/tart/Credentials/EnvironmentCredentialsProvider.swift index c91e273..2102de0 100644 --- a/Sources/tart/Credentials/EnvironmentCredentialsProvider.swift +++ b/Sources/tart/Credentials/EnvironmentCredentialsProvider.swift @@ -1,6 +1,8 @@ import Foundation class EnvironmentCredentialsProvider: CredentialsProvider { + let userFriendlyName = "environment variable credentials provider" + func retrieve(host: String) throws -> (String, String)? { if let tartRegistryHostname = ProcessInfo.processInfo.environment["TART_REGISTRY_HOSTNAME"], tartRegistryHostname != host { diff --git a/Sources/tart/Credentials/KeychainCredentialsProvider.swift b/Sources/tart/Credentials/KeychainCredentialsProvider.swift index be9d272..35c0293 100644 --- a/Sources/tart/Credentials/KeychainCredentialsProvider.swift +++ b/Sources/tart/Credentials/KeychainCredentialsProvider.swift @@ -1,6 +1,8 @@ import Foundation class KeychainCredentialsProvider: CredentialsProvider { + let userFriendlyName = "Keychain credentials provider" + func retrieve(host: String) throws -> (String, String)? { let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword, kSecAttrProtocol as String: kSecAttrProtocolHTTPS, diff --git a/Sources/tart/Credentials/StdinCredentials.swift b/Sources/tart/Credentials/StdinCredentials.swift index 2b55dd9..a6591ac 100644 --- a/Sources/tart/Credentials/StdinCredentials.swift +++ b/Sources/tart/Credentials/StdinCredentials.swift @@ -6,6 +6,8 @@ enum StdinCredentialsError: Error { } class StdinCredentials { + let userFriendlyName = "standard input credentials provider" + static func retrieve() throws -> (String, String) { let user = try readStdinCredential(name: "username", prompt: "User: ", isSensitive: false) let password = try readStdinCredential(name: "password", prompt: "Password: ", isSensitive: true) diff --git a/Sources/tart/OCI/Registry.swift b/Sources/tart/OCI/Registry.swift index 4281002..25d13cb 100644 --- a/Sources/tart/OCI/Registry.swift +++ b/Sources/tart/OCI/Registry.swift @@ -429,8 +429,12 @@ class Registry { } for provider in credentialsProviders { - if let (user, password) = try provider.retrieve(host: host) { - return (user, password) + do { + if let (user, password) = try provider.retrieve(host: host) { + return (user, password) + } + } catch (let e) { + print("Failed to retrieve credentials using \(provider.userFriendlyName), authentication may fail: \(e)") } } return nil From a655edd826d1c82205c860fae3ce91dd3ccccc3b Mon Sep 17 00:00:00 2001 From: jxlwqq Date: Tue, 23 Sep 2025 03:12:45 +0800 Subject: [PATCH 06/20] docs: update sshpass command to ignore known hosts file (#1136) Co-authored-by: jinxiaolong --- docs/quick-start.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/quick-start.md b/docs/quick-start.md index 0a82211..ec053df 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -95,8 +95,8 @@ ssh admin@$(tart ip sequoia-base) ```bash brew install cirruslabs/cli/sshpass - sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip sequoia-base) "uname -a" - sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip sequoia-base) < script.sh + sshpass -p admin ssh -o "StrictHostKeyChecking no" -o "UserKnownHostsFile=/dev/null" admin@$(tart ip sequoia-base) "uname -a" + sshpass -p admin ssh -o "StrictHostKeyChecking no" -o "UserKnownHostsFile=/dev/null" admin@$(tart ip sequoia-base) < script.sh ``` ## Mounting directories From 84147f29b52fa57f979912f2153c1200e8ea4a83 Mon Sep 17 00:00:00 2001 From: Nikolay Edigaryev Date: Mon, 22 Sep 2025 22:02:39 +0200 Subject: [PATCH 07/20] Document automatic resources set by the Orchard Worker (#1134) * Fix MkDocs warnings w.r.t. absolute instead of relative links * Document automatic resources set by the Orchard Worker * .markdownlint.yml: ignore MD051 --- docs/.markdownlint.yml | 1 + .../posts/2023-02-11-changing-tart-license.md | 6 +++--- docs/blog/posts/2023-04-25-orchard-ga.md | 2 +- .../posts/2023-04-28-orchard-ssh-over-grpc.md | 2 +- docs/blog/posts/2023-09-20-tart-2.0.0.md | 2 +- docs/blog/posts/2023-10-06-tart-on-aws.md | 2 +- .../2023-11-03-cirrus-runners-dashboard.md | 4 ++-- docs/integrations/buildkite.md | 2 +- docs/integrations/cirrus-cli.md | 2 +- docs/orchard/architecture-and-security.md | 8 ++++---- docs/orchard/deploying-controller.md | 2 +- docs/orchard/integration-guide.md | 2 +- docs/orchard/quick-start.md | 18 +++++++++--------- docs/orchard/using-orchard-cli.md | 9 +++++++++ 14 files changed, 36 insertions(+), 26 deletions(-) diff --git a/docs/.markdownlint.yml b/docs/.markdownlint.yml index 612944e..8796a9c 100644 --- a/docs/.markdownlint.yml +++ b/docs/.markdownlint.yml @@ -11,3 +11,4 @@ "MD045": false # OK not to have a description for an image "MD046": false # Code block style [Expected: fenced; Actual: indented] "MD059": false # It's OK to have "here" links +"MD051": false # MkDocs generates "#-no-pki" anchors, but markdownlint expects "#--no-pki" anchors diff --git a/docs/blog/posts/2023-02-11-changing-tart-license.md b/docs/blog/posts/2023-02-11-changing-tart-license.md index e094d14..3e70e37 100644 --- a/docs/blog/posts/2023-02-11-changing-tart-license.md +++ b/docs/blog/posts/2023-02-11-changing-tart-license.md @@ -60,9 +60,9 @@ device without a physical display connected. For example, a Mac Mini with a HDMI but a Mac Mini on a desk with a connected physical display is considered a personal computer. **Usage on personal computers and before reaching the 100 CPU cores limit is royalty-free and does not have the viral properties of AGPL.** -When an organization surpasses the 100 CPU cores limit, they will be required to obtain a [Gold Tier License](/licensing#license-tiers), -which costs \$1000 per month. Upon reaching a limit of 500 CPU cores, a [Platinum Tier License](/licensing#license-tiers) -(\$3000 per month) will be required, and for organizations that exceed 3000 CPU cores, a custom [Diamond Tier License](/licensing#license-tiers) +When an organization surpasses the 100 CPU cores limit, they will be required to obtain a [Gold Tier License](../../licensing.md#license-tiers), +which costs \$1000 per month. Upon reaching a limit of 500 CPU cores, a [Platinum Tier License](../../licensing.md#license-tiers) +(\$3000 per month) will be required, and for organizations that exceed 3000 CPU cores, a custom [Diamond Tier License](../../licensing.md#license-tiers) (\$1 per core per month) will be necessary. **All paid license tiers will include priority feature development and SLAs on support with urgent issues.** ## Have we considered alternatives? diff --git a/docs/blog/posts/2023-04-25-orchard-ga.md b/docs/blog/posts/2023-04-25-orchard-ga.md index 79fe24f..e30c796 100644 --- a/docs/blog/posts/2023-04-25-orchard-ga.md +++ b/docs/blog/posts/2023-04-25-orchard-ga.md @@ -89,6 +89,6 @@ orchard dev This will launch a development cluster with a single worker on your machine. Refer to [Orchard documentation](https://github.com/cirruslabs/orchard#creating-virtual-machines) on how to create your first virtual machine and access it. -In a [separate blog post](/blog/2023/04/28/ssh-over-grpc-or-how-orchard-simplifies-accessing-vms-in-private-networks/) +In a [separate blog post](2023-04-28-orchard-ssh-over-grpc.md) we’ll cover how Orchard implements seamless SSH access over a gRPC connection. Stay tuned and please don’t hesitate to [reach out](https://github.com/cirruslabs/orchard/discussions/landing)! diff --git a/docs/blog/posts/2023-04-28-orchard-ssh-over-grpc.md b/docs/blog/posts/2023-04-28-orchard-ssh-over-grpc.md index 8acbbc4..d107e42 100644 --- a/docs/blog/posts/2023-04-28-orchard-ssh-over-grpc.md +++ b/docs/blog/posts/2023-04-28-orchard-ssh-over-grpc.md @@ -64,7 +64,7 @@ We’ve also initially considered using [Yamux](https://github.com/hashicorp/yam First of all, we’ve made the new port-forwarding functionality available for integrations via the Orchard’s REST API: -![OpenAPI documentation for Orchard's port-forwarding endpoint](/assets/images/orchard-port-forwarding-api.png) +![OpenAPI documentation for Orchard's port-forwarding endpoint](../../assets/images/orchard-port-forwarding-api.png) All you need is to use a WebSocket client when accessing this endpoint to make it work. diff --git a/docs/blog/posts/2023-09-20-tart-2.0.0.md b/docs/blog/posts/2023-09-20-tart-2.0.0.md index 1c01a9e..0754277 100644 --- a/docs/blog/posts/2023-09-20-tart-2.0.0.md +++ b/docs/blog/posts/2023-09-20-tart-2.0.0.md @@ -43,7 +43,7 @@ allocate time to continue improving Tart which brings us to the section below. In the last 7 months we've had 12 feature releases that brought a lot of features requested by the community. Here are just a few of them to highlight: --[Custom GitLab Runner Executor](/integrations/gitlab-runner/). +-[Custom GitLab Runner Executor](../../integrations/gitlab-runner.md). -[Cluster Management via Orchard](2023-04-25-orchard-ga.md). -Numerous compatibility improvements for all kinds of OCI-registries. -Sonoma Support (see details [below](#macos-sonoma-updates)). diff --git a/docs/blog/posts/2023-10-06-tart-on-aws.md b/docs/blog/posts/2023-10-06-tart-on-aws.md index 75e124d..1b84dce 100644 --- a/docs/blog/posts/2023-10-06-tart-on-aws.md +++ b/docs/blog/posts/2023-10-06-tart-on-aws.md @@ -17,7 +17,7 @@ with preconfigured Tart installation that is optimized to work within AWS infras 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! -![EC2 M2 Pro](/blog/images/ec2-mac2-m2pro.png) +![EC2 M2 Pro](../images/ec2-mac2-m2pro.png) This clever synergy between Apple Hardware and Nitro System allows seamless integration with VPC networking and booting macOS from an EBS volume. diff --git a/docs/blog/posts/2023-11-03-cirrus-runners-dashboard.md b/docs/blog/posts/2023-11-03-cirrus-runners-dashboard.md index f639142..36dbace 100644 --- a/docs/blog/posts/2023-11-03-cirrus-runners-dashboard.md +++ b/docs/blog/posts/2023-11-03-cirrus-runners-dashboard.md @@ -34,7 +34,7 @@ than recently announced Apple Silicon GitHub-manged runners that cost $0.16 per 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) +![Cirrus Runners Dashboard](../images/runners-price-performance-2.png) As you can see above Cirrus Runners Dashboard focuses on 4 core metrics: @@ -50,7 +50,7 @@ we can see that the downside of such great price performance is that jobs are wa 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) +![Cirrus Runners Dashboard](../images/runners-price-performance-3.png) ## Conclusion diff --git a/docs/integrations/buildkite.md b/docs/integrations/buildkite.md index c732ff5..285b611 100644 --- a/docs/integrations/buildkite.md +++ b/docs/integrations/buildkite.md @@ -7,7 +7,7 @@ description: Run pipeline steps in isolated ephemeral Tart Virtual Machines. 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) +![](../assets/images/BuildkiteTartPlugin.png) ## Configuration diff --git a/docs/integrations/cirrus-cli.md b/docs/integrations/cirrus-cli.md index fe3e3a5..c1dcee8 100644 --- a/docs/integrations/cirrus-cli.md +++ b/docs/integrations/cirrus-cli.md @@ -33,7 +33,7 @@ brew install cirruslabs/cli/cirrus cirrus run ``` -![](/assets/images/TartCirrusCLI.gif) +![](../assets/images/TartCirrusCLI.gif) [Cirrus CI](https://cirrus-ci.org/) already leverages Tart to power its macOS cloud infrastructure. The `.cirrus.yml` config from above will just work in Cirrus CI and your tasks will be executed inside Tart VMs in our cloud. diff --git a/docs/orchard/architecture-and-security.md b/docs/orchard/architecture-and-security.md index 4667161..9f02712 100644 --- a/docs/orchard/architecture-and-security.md +++ b/docs/orchard/architecture-and-security.md @@ -4,7 +4,7 @@ Orchard cluster consists of three components: * Controller — responsible for managing the cluster and scheduling of resources * Worker — responsible for executing the VMs -* Client — responsible for creating, modifying and removing the resources on the Controller, can either be an [Orchard CLI](/orchard/using-orchard-cli) or [an API consumer](/orchard/integration-guide) +* Client — responsible for creating, modifying and removing the resources on the Controller, can either be an [Orchard CLI](using-orchard-cli.md) or [an API consumer](integration-guide.md) At the moment, only one Controller instance is currently supported, while you can deploy one or more Workers and run any number of Clients. @@ -14,7 +14,7 @@ In terms of networking requirements, only Controller needs to be directly access When an Orchard Client or a Worker connects to the Controller, they need to establish trust and verify that they're talking to the right Controller, so that no [man-in-the-middle attack](https://en.wikipedia.org/wiki/Man-in-the-middle_attack) is possible. -Similarly to web-browsers (that rely on the [public key infrastructure](https://en.wikipedia.org/wiki/Public_key_infrastructure)) and SSH (which relies on semi-automated fingerprint verification), Orchard combines these two traits in a hybrid approach by defaulting to automatic PKI verification (can be disabled by [`--no-pki`](#--no-pki-override)) and falling-back to a manual verification for self-signed certificates. +Similarly to web-browsers (that rely on the [public key infrastructure](https://en.wikipedia.org/wiki/Public_key_infrastructure)) and SSH (which relies on semi-automated fingerprint verification), Orchard combines these two traits in a hybrid approach by defaulting to automatic PKI verification (can be disabled by [`--no-pki`](#-no-pki-override)) and falling-back to a manual verification for self-signed certificates. This hybrid approach is needed because the Controller can be configured in two ways: @@ -29,7 +29,7 @@ Below we'll explain how Orchard client and Worker secure the connection when acc Client is associated with the Controller using a `orchard context create` command, which works as follows: -* Client attempts to connect to the Controller and validate its certificate using host's root CA set (can be disabled with [`--no-pki`](#--no-pki-override)) +* Client attempts to connect to the Controller and validate its certificate using host's root CA set (can be disabled with [`--no-pki`](#-no-pki-override)) * if the Client encounters a *Controller with a publicly valid certificate*, that would be the last step and the association would succeed * if the Client is dealing with *Controller with a self-signed certificate*, the Client will do another connection attempt to probe the Controller's certificate * the probed Controller's certificate fingerprint is then presented to the user, and if the user agrees to trust it, the Client then considers that certificate to be trusted for a given context @@ -53,7 +53,7 @@ The way Worker connects to the Controller using the `orchard worker run` command * when the Bootstrap Token contains the Controller's certificate: * the Orchard Worker will try to connect to the Controller with a trusted CA set containing only that certificate * when the Bootstrap Token has no Controller's certificate: - * the Orchard Worker will try the PKI approach (can be disabled with [`--no-pki`](#--no-pki-override) to effectively prevent the Worker from connecting) and fail if certificate verification using PKI is not possible + * the Orchard Worker will try the PKI approach (can be disabled with [`--no-pki`](#-no-pki-override) to effectively prevent the Worker from connecting) and fail if certificate verification using PKI is not possible ### `--no-pki` override diff --git a/docs/orchard/deploying-controller.md b/docs/orchard/deploying-controller.md index e7cbb3c..abb47cd 100644 --- a/docs/orchard/deploying-controller.md +++ b/docs/orchard/deploying-controller.md @@ -53,7 +53,7 @@ Here's other command-line arguments associated with this functionality: * `--insecure-ssh-no-client-auth` — allow SSH clients to connect to the controller's SSH server without authentication, thus only authenticating on the target worker/VM's SSH server * useful when you already have strong credentials on your VMs, and you want to share these VMs to others without additionally giving out Orchard Cluster credentials -Check out our [Jumping through the hoops: SSH jump host functionality in Orchard](/blog/2024/06/20/jumping-through-the-hoops-ssh-jump-host-functionality-in-orchard/) blog post for more information. +Check out our [Jumping through the hoops: SSH jump host functionality in Orchard](../blog/posts/2024-06-20-jumping-through-the-hoops.md) blog post for more information. ## Deployment Methods diff --git a/docs/orchard/integration-guide.md b/docs/orchard/integration-guide.md index 09701a4..6c7a025 100644 --- a/docs/orchard/integration-guide.md +++ b/docs/orchard/integration-guide.md @@ -2,7 +2,7 @@ Orchard has a REST API that follows [OpenAPI specification](https://swagger.io/s You can run `orchard dev` locally and navigate to `http://127.0.0.1:6120/v1/` for interactive documentation. -![](/assets/images/orchard/orchard-api-documentation-browser.png) +![](../assets/images/orchard/orchard-api-documentation-browser.png) ## Using the API diff --git a/docs/orchard/quick-start.md b/docs/orchard/quick-start.md index f9245d2..9d3507c 100644 --- a/docs/orchard/quick-start.md +++ b/docs/orchard/quick-start.md @@ -4,7 +4,7 @@ a couple of VMs is not enough anymore for your needs? This is where [Orchard](ht comes in to play! It allows you to orchestrate multiple Tart-capable hosts from either an Orchard CLI (which we demonstrate below) -or [through the API](/orchard/integration-guide). +or [through the API](integration-guide.md). The easiest way to start is to run Orchard in local development mode: @@ -18,7 +18,7 @@ test both the CLI functionality and the API from a tool like cURL or programming authenticate requests. Note that in production deployments, these two components are started separately and enable security by default. Please -refer to [Deploying Controller](/orchard/deploying-controller) and [Deploying Workers](/orchard/deploying-workers) for +refer to [Deploying Controller](deploying-controller.md) and [Deploying Workers](deploying-workers.md) for more information. ## Creating Virtual Machines @@ -92,10 +92,10 @@ orchard delete vm sequoia-base In addition to controlling the Orchard via the CLI arguments, there are environment variables that may be beneficial both when automating Orchard and in daily use: -| Variable name | Description | -|---------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ORCHARD_HOME` | Override Orchard's home directory. Useful when running multiple Orchard instances on the same host and when testing. | -| `ORCHARD_LICENSE_TIER` | The default license limit only allows connecting 4 Orchard Workers to the Orchard Controller. If you've purchased a [Gold Tier License](/licensing/), set this variable to `gold` to increase the limit to 20 Orchard Workers. And if you've purchased a [Platinum Tier License](/licensing/), set this variable to `platinum` to increase the limit to 200 Orchard Workers. | -| `ORCHARD_URL` | Override controller URL on per-command basis. | -| `ORCHARD_SERVICE_ACCOUNT_NAME` | Override service account name (used for controller API auth) on per-command basis. | -| `ORCHARD_SERVICE_ACCOUNT_TOKEN` | Override service account token (used for controller API auth) on per-command basis. | +| Variable name | Description | +|---------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ORCHARD_HOME` | Override Orchard's home directory. Useful when running multiple Orchard instances on the same host and when testing. | +| `ORCHARD_LICENSE_TIER` | The default license limit only allows connecting 4 Orchard Workers to the Orchard Controller. If you've purchased a [Gold Tier License](../licensing.md), set this variable to `gold` to increase the limit to 20 Orchard Workers. And if you've purchased a [Platinum Tier License](../licensing.md), set this variable to `platinum` to increase the limit to 200 Orchard Workers. | +| `ORCHARD_URL` | Override controller URL on per-command basis. | +| `ORCHARD_SERVICE_ACCOUNT_NAME` | Override service account name (used for controller API auth) on per-command basis. | +| `ORCHARD_SERVICE_ACCOUNT_TOKEN` | Override service account token (used for controller API auth) on per-command basis. | diff --git a/docs/orchard/using-orchard-cli.md b/docs/orchard/using-orchard-cli.md index ba10679..8ba5db8 100644 --- a/docs/orchard/using-orchard-cli.md +++ b/docs/orchard/using-orchard-cli.md @@ -75,3 +75,12 @@ orchard create vm --resources bandwidth-mbps=7500 However, after this VM is scheduled, the 10 Gbps Mac Studio will only be able to accommodate one more VM (due to internal Apple EULA limit for macOS virtualization) with `bandwidth-mbps=2500` or less. After the VM finishes, the unused resources will be available again. + +## Automatic resources + +In addition to manually specifying resources when starting a worker, the following resources are discovered and set automatically by the worker for convenience: + +* `org.cirruslabs.logical-cores` — number of logical cores on the host +* `org.cirruslabs.memory-mib` — total memory in MiB (mebibytes) on the host + +Note that the values for these resources are scraped only once at worker startup. From e3ee2da2fdaa0af9a6a84cbc9e2c916c87d05e4c Mon Sep 17 00:00:00 2001 From: Nikolay Edigaryev Date: Thu, 25 Sep 2025 18:44:57 +0200 Subject: [PATCH 08/20] Validate custom TART_HOME and provide a human-friendly error message (#1138) * Validate custom TART_HOME and provide a human-friendly error message * Safer way to calculate "descendingURLs" --- Sources/tart/Commands/Clone.swift | 4 ++-- Sources/tart/Commands/Import.swift | 2 +- Sources/tart/Commands/Prune.swift | 6 +++--- Sources/tart/Commands/Pull.swift | 2 +- Sources/tart/Commands/Push.swift | 2 +- Sources/tart/Commands/Rename.swift | 2 +- Sources/tart/Commands/Run.swift | 4 ++-- Sources/tart/Config.swift | 23 ++++++++++++++++++++++- Sources/tart/Root.swift | 2 +- Sources/tart/VMStorageLocal.swift | 6 +++++- Sources/tart/VMStorageOCI.swift | 6 +++++- 11 files changed, 44 insertions(+), 15 deletions(-) diff --git a/Sources/tart/Commands/Clone.swift b/Sources/tart/Commands/Clone.swift index 0b38850..b6497e2 100644 --- a/Sources/tart/Commands/Clone.swift +++ b/Sources/tart/Commands/Clone.swift @@ -45,8 +45,8 @@ struct Clone: AsyncParsableCommand { } func run() async throws { - let ociStorage = VMStorageOCI() - let localStorage = VMStorageLocal() + let ociStorage = try VMStorageOCI() + let localStorage = try VMStorageLocal() if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) { // Pull the VM in case it's OCI-based and doesn't exist locally yet diff --git a/Sources/tart/Commands/Import.swift b/Sources/tart/Commands/Import.swift index 645b5bd..edb0253 100644 --- a/Sources/tart/Commands/Import.swift +++ b/Sources/tart/Commands/Import.swift @@ -17,7 +17,7 @@ struct Import: AsyncParsableCommand { } func run() async throws { - let localStorage = VMStorageLocal() + let localStorage = try VMStorageLocal() // Create a temporary VM directory to which we will load the export file let tmpVMDir = try VMDirectory.temporary() diff --git a/Sources/tart/Commands/Prune.swift b/Sources/tart/Commands/Prune.swift index 5e41e03..68f9bfa 100644 --- a/Sources/tart/Commands/Prune.swift +++ b/Sources/tart/Commands/Prune.swift @@ -53,9 +53,9 @@ struct Prune: AsyncParsableCommand { switch entries { case "caches": - prunableStorages = [VMStorageOCI(), try IPSWCache()] + prunableStorages = [try VMStorageOCI(), try IPSWCache()] case "vms": - prunableStorages = [VMStorageLocal()] + prunableStorages = [try VMStorageLocal()] default: throw ValidationError("unsupported --entries value, please specify either \"caches\" or \"vms\"") } @@ -152,7 +152,7 @@ struct Prune: AsyncParsableCommand { let transaction = SentrySDK.startTransaction(name: "Pruning cache", operation: "prune", bindToScope: true) defer { transaction.finish() } - let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()] + let prunableStorages: [PrunableStorage] = [try VMStorageOCI(), try IPSWCache()] let prunables: [Prunable] = try prunableStorages .flatMap { try $0.prunables() } .sorted { try $0.accessDate() < $1.accessDate() } diff --git a/Sources/tart/Commands/Pull.swift b/Sources/tart/Commands/Pull.swift index 02c5e29..0d66658 100644 --- a/Sources/tart/Commands/Pull.swift +++ b/Sources/tart/Commands/Pull.swift @@ -35,7 +35,7 @@ struct Pull: AsyncParsableCommand { func run() async throws { // Be more liberal when accepting local image as argument, // see https://github.com/cirruslabs/tart/issues/36 - if VMStorageLocal().exists(remoteName) { + if try VMStorageLocal().exists(remoteName) { print("\"\(remoteName)\" is a local image, nothing to pull here!") return diff --git a/Sources/tart/Commands/Push.swift b/Sources/tart/Commands/Push.swift index 79ede79..5b48b21 100644 --- a/Sources/tart/Commands/Push.swift +++ b/Sources/tart/Commands/Push.swift @@ -39,7 +39,7 @@ struct Push: AsyncParsableCommand { var populateCache: Bool = false func run() async throws { - let ociStorage = VMStorageOCI() + let ociStorage = try VMStorageOCI() let localVMDir = try VMStorageHelper.open(localName) let lock = try localVMDir.lock() if try !lock.trylock() { diff --git a/Sources/tart/Commands/Rename.swift b/Sources/tart/Commands/Rename.swift index 66ad9bd..194b031 100644 --- a/Sources/tart/Commands/Rename.swift +++ b/Sources/tart/Commands/Rename.swift @@ -17,7 +17,7 @@ struct Rename: AsyncParsableCommand { } func run() async throws { - let localStorage = VMStorageLocal() + let localStorage = try VMStorageLocal() if !localStorage.exists(name) { throw ValidationError("failed to rename a non-existent local VM: \(name)") diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index fd7a851..52831a4 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -301,7 +301,7 @@ struct Run: AsyncParsableCommand { } } - let localStorage = VMStorageLocal() + let localStorage = try VMStorageLocal() let vmDir = try localStorage.open(name) if try vmDir.state() == .Suspended { suspendable = true @@ -334,7 +334,7 @@ struct Run: AsyncParsableCommand { @MainActor func run() async throws { - let localStorage = VMStorageLocal() + let localStorage = try VMStorageLocal() let vmDir = try localStorage.open(name) // Validate disk format support diff --git a/Sources/tart/Config.swift b/Sources/tart/Config.swift index 05b295f..64af7de 100644 --- a/Sources/tart/Config.swift +++ b/Sources/tart/Config.swift @@ -9,7 +9,8 @@ struct Config { var tartHomeDir: URL if let customTartHome = ProcessInfo.processInfo.environment["TART_HOME"] { - tartHomeDir = URL(fileURLWithPath: customTartHome) + tartHomeDir = URL(fileURLWithPath: customTartHome, isDirectory: true) + try Self.validateTartHome(url: tartHomeDir) } else { tartHomeDir = FileManager.default .homeDirectoryForCurrentUser @@ -49,4 +50,24 @@ struct Config { static func jsonDecoder() -> JSONDecoder { JSONDecoder() } + + private static func validateTartHome(url: URL) throws { + let urlComponents = url.pathComponents + + let descendingURLs = urlComponents.indices.map { i in + URL(fileURLWithPath: urlComponents[0...i].joined(separator: "/")) + } + + for descendingURL in descendingURLs { + if FileManager.default.fileExists(atPath: descendingURL.path) { + continue + } + + do { + try FileManager.default.createDirectory(at: descendingURL, withIntermediateDirectories: false) + } catch { + throw RuntimeError.Generic("TART_HOME is invalid: \(descendingURL.path) does not exist, yet we can't create it: \(error.localizedDescription)") + } + } + } } diff --git a/Sources/tart/Root.swift b/Sources/tart/Root.swift index 43a5721..477e6f9 100644 --- a/Sources/tart/Root.swift +++ b/Sources/tart/Root.swift @@ -92,7 +92,7 @@ struct Root: AsyncParsableCommand { do { try Config().gc() } catch { - fputs("Failed to perform garbage collection!\n\(error)\n", stderr) + fputs("Failed to perform garbage collection: \(error)\n", stderr) } } diff --git a/Sources/tart/VMStorageLocal.swift b/Sources/tart/VMStorageLocal.swift index 47b5b27..6a39420 100644 --- a/Sources/tart/VMStorageLocal.swift +++ b/Sources/tart/VMStorageLocal.swift @@ -1,7 +1,11 @@ import Foundation class VMStorageLocal: PrunableStorage { - let baseURL: URL = try! Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true) + let baseURL: URL + + init() throws { + baseURL = try Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true) + } private func vmURL(_ name: String) -> URL { baseURL.appendingPathComponent(name, isDirectory: true) diff --git a/Sources/tart/VMStorageOCI.swift b/Sources/tart/VMStorageOCI.swift index f71c547..a92b748 100644 --- a/Sources/tart/VMStorageOCI.swift +++ b/Sources/tart/VMStorageOCI.swift @@ -3,7 +3,11 @@ import Sentry import Retry class VMStorageOCI: PrunableStorage { - let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true) + let baseURL: URL + + init() throws { + baseURL = try Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true) + } private func vmURL(_ name: RemoteName) -> URL { baseURL.appendingRemoteName(name) From 839c6e75620ddb241240458e566b21a3218c7f23 Mon Sep 17 00:00:00 2001 From: fsc-eriker <72394365+fsc-eriker@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:43:14 +0300 Subject: [PATCH 09/20] Update faq.md: Use question word order in subheading (#1143) "How Tart is different from Anka" is not a question, and thus should not have a question mark. This PR proposes to change it into a question, but an equally valid fix is to drop the question mark. --- docs/faq.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/faq.md b/docs/faq.md index a4a9dfe..5c361ff 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -150,7 +150,7 @@ This command also supports the `-p` command-line argument that allows you to sup If that doesn't work for you for some reason, you can pass the credentials via the environment variables, see [Registry Authorization](integrations/vm-management.md#registry-authorization) for more details on how to do that. -## How Tart is different from Anka? +## How is Tart different from Anka? Under the hood Tart is using the same technology as Anka 3.0 so there should be no real difference in performance or features supported. If there is some feature missing please don't hesitate to [create a feature request](https://github.com/cirruslabs/tart/issues). From 590e064e358add9e0d243cd10bf49a892ae57800 Mon Sep 17 00:00:00 2001 From: fsc-eriker <72394365+fsc-eriker@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:43:48 +0300 Subject: [PATCH 10/20] Update faq.md: Avoid useless use of grep | awk (#1142) In "Connecting to a service running on host", refactor to a single Awk script in favor of grep | head | awk --- docs/faq.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/faq.md b/docs/faq.md index 5c361ff..da19101 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -34,7 +34,7 @@ Then from within a virtual machine you can access the service using the router's or by running the following command in the Terminal: ```shell -netstat -nr | grep default | head -n 1 | awk '{print $2}' +netstat -nr | awk '/default/{print $2; exit}' ``` Note: that accessing host is only possible with the default NAT network. If you are running your virtual machines with From eff964b62a7a5fd3d6e784795a652e2a923c3542 Mon Sep 17 00:00:00 2001 From: Stefan Mitterrutzner Date: Mon, 29 Sep 2025 13:57:39 +0200 Subject: [PATCH 11/20] Avoid duplicate progress updates in CI logs (#1140) * Avoid duplicate progress updates in CI logs * Update Sources/tart/Logging/ProgressObserver.swift Co-authored-by: Nikolay Edigaryev --------- Co-authored-by: Nikolay Edigaryev --- Sources/tart/Logging/ProgressObserver.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Sources/tart/Logging/ProgressObserver.swift b/Sources/tart/Logging/ProgressObserver.swift index c75ee97..48228f3 100644 --- a/Sources/tart/Logging/ProgressObserver.swift +++ b/Sources/tart/Logging/ProgressObserver.swift @@ -4,18 +4,28 @@ public class ProgressObserver: NSObject { @objc var progressToObserve: Progress var observation: NSKeyValueObservation? var lastTimeUpdated = Date.now + private var lastRenderedLine: String? public init(_ progress: Progress) { progressToObserve = progress } func log(_ renderer: Logger) { - renderer.appendNewLine(ProgressObserver.lineToRender(progressToObserve)) + let initialLine = ProgressObserver.lineToRender(progressToObserve) + renderer.appendNewLine(initialLine) + lastRenderedLine = initialLine observation = observe(\.progressToObserve.fractionCompleted) { progress, _ in let currentTime = Date.now if self.progressToObserve.isFinished || currentTime.timeIntervalSince(self.lastTimeUpdated) >= 1.0 { self.lastTimeUpdated = currentTime - renderer.updateLastLine(ProgressObserver.lineToRender(self.progressToObserve)) + let line = ProgressObserver.lineToRender(self.progressToObserve) + // Skip identical renders so non-interactive logs only see new percent values. + if line == self.lastRenderedLine { + return + } + + self.lastRenderedLine = line + renderer.updateLastLine(line) } } } From d762fe6fc1db6f319aad31ea0f331a4af27196d4 Mon Sep 17 00:00:00 2001 From: Nikolay Edigaryev Date: Wed, 8 Oct 2025 14:44:05 +0200 Subject: [PATCH 12/20] tart run: do not recommend running "tart run" as root (#1147) --- Sources/tart/Commands/Run.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index 52831a4..2d334c2 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -134,11 +134,12 @@ struct Run: AsyncParsableCommand { Learn how to create a disk image using Disk Utility here: https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac - To work with block devices, the easiest way is to modify their permissions (e.g. by using "sudo chown $USER /dev/diskX") or to run the Tart binary as root, which affects locating Tart VMs. + To work with block devices, the easiest way is to modify their permissions to be accessible to the current user: - To work around this pass TART_HOME explicitly: + sudo chown $USER /dev/diskX + tart run sequoia --disk=/dev/diskX - sudo TART_HOME="$HOME/.tart" tart run sequoia --disk=/dev/disk0 + Warning: after running the chown command above, all software running under the current user will be able to access /dev/diskX. If that violates your threat model, we recommend avoiding mounting block devices altogether. """, valueName: "path[:options]"), completion: .file()) var disk: [String] = [] From b05c731510e82c413da3c130552fb5c80b7e020c Mon Sep 17 00:00:00 2001 From: Nikolay Edigaryev Date: Wed, 8 Oct 2025 20:19:02 +0200 Subject: [PATCH 13/20] FAQ: document creation and unlocking of the keychain headless machines (#1148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * FAQ: document creation and unlocking of the keychain headless machines * Remove extra spaces * Fix typo: this commands → this command --- docs/faq.md | 42 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/docs/faq.md b/docs/faq.md index da19101..74f2912 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -5,6 +5,42 @@ title: Frequently Asked Questions description: Advanced configuration and troubleshooting tips for advanced configurations. --- +## Headless machines + +Starting from macOS 15 (Sequoia), there's an undocumented requirement from [Virtualization.Framework](https://developer.apple.com/documentation/virtualization) (which Tart uses) to have an unlocked `login.keychain` available at the times when running a VM. + +Without an existing and unlocked `login.keychain`, the VM won't start with errors like: + +* `SecKeyCreateRandomKey_ios failed` +* `Failed to generate keypair` +* `Interaction is not allowed with the Security Server` + +Below you'll find a couple of workarounds for this behavior. + +### Log in via GUI at least once + +Connect to the headless machine via [Screen Sharing](https://support.apple.com/guide/mac-help/share-the-screen-of-another-mac-mh14066/mac) and log in to a Mac user account. If you haven't done already, you can enable Screen Sharing [via the terminal](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/connect-to-mac-instance.html#mac-instance-vnc). + +Logging in graphically will automatically create the `login.keychain`. Afterward, you have two options: + +* configure [automatic log in to a Mac user account](https://support.apple.com/en-us/102316) + * this will maintain a running user session (GUI) even after the machine reboots + * moreover, you can still lock the screen (either manually [or automatically](https://support.apple.com/guide/mac-help/change-lock-screen-settings-on-mac-mh11784/mac)), however, the security benefit of this is questionable +* use `security unlock-keychain login.keychain` to unlock the login keychain via the terminal + * this command also supports the `-p` command-line argument, which allows you to supply a password and unlock non-interactively + +### Create and unlock the login keychain via the terminal + +Compared to the previous approach, this one is fully automated, but might stop working at some point in the future: + +```shell +security create-keychain -p '' login.keychain +security unlock-keychain -p '' login.keychain +security login-keychain -s login.keychain +``` + +Note that this will create a `login.keychain` with an empty password. Consider supplying a different value to `-p` or omitting the `-p` to enter the password interactively. + ## Troubleshooting crashes If you experience a crash or encounter another error while using the tart executable, you can collect debug information to assist with troubleshooting. Run the following command in a separate terminal window to gather logs from the Tart process and the macOS Virtualization subsystem: @@ -143,12 +179,12 @@ This is because Tart uses [Keychain](https://en.wikipedia.org/wiki/Keychain_(sof To unlock the Keychain in an SSH session, run the following command, which will ask for your user's password: ```shell -security unlock-keychain +security unlock-keychain login.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. +This command also supports the `-p` command-line argument that allows you to supply a 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. +Alternatively, you can pass the credentials via the environment variables, see [Registry Authorization](integrations/vm-management.md#registry-authorization) for more details on how to do that. ## How is Tart different from Anka? From 90d950013385be8230ac68514fdcd5b516c88956 Mon Sep 17 00:00:00 2001 From: Eric Kolve Date: Thu, 9 Oct 2025 12:29:28 -0700 Subject: [PATCH 14/20] chore: adding no-keyboard, no-pointer options for run (#1091) --- Sources/tart/Commands/Run.swift | 17 ++++++++++++++++- Sources/tart/VM.swift | 24 +++++++++++++++++++----- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index 2d334c2..2af1783 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -266,6 +266,12 @@ struct Run: AsyncParsableCommand { #endif var noTrackpad: Bool = false + @Flag(help: ArgumentHelp("Disable the pointer")) + var noPointer: Bool = false + + @Flag(help: ArgumentHelp("Disable the keyboard")) + var noKeyboard: Bool = false + mutating func validate() throws { if vnc && vncExperimental { throw ValidationError("--vnc and --vnc-experimental are mutually exclusive") @@ -317,8 +323,15 @@ struct Run: AsyncParsableCommand { if noTrackpad { throw ValidationError("--no-trackpad cannot be used with --suspendable") } + if noKeyboard { + throw ValidationError("--no-keyboard cannot be used with --suspendable") + } + if noPointer { + throw ValidationError("--no-pointer cannot be used with --suspendable") + } } + if noTrackpad { let config = try VMConfig.init(fromURL: vmDir.configURL) if config.os != .darwin { @@ -394,7 +407,9 @@ struct Run: AsyncParsableCommand { clipboard: !noClipboard, sync: VZDiskImageSynchronizationMode(diskOptions.syncModeRaw), caching: VZDiskImageCachingMode(diskOptions.cachingModeRaw), - noTrackpad: noTrackpad + noTrackpad: noTrackpad, + noPointer: noPointer, + noKeyboard: noKeyboard ) let vncImpl: VNC? = try { diff --git a/Sources/tart/VM.swift b/Sources/tart/VM.swift index 15b3cc9..fc8fc7a 100644 --- a/Sources/tart/VM.swift +++ b/Sources/tart/VM.swift @@ -51,7 +51,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { clipboard: Bool = true, sync: VZDiskImageSynchronizationMode = .full, caching: VZDiskImageCachingMode? = nil, - noTrackpad: Bool = false + noTrackpad: Bool = false, + noPointer: Bool = false, + noKeyboard: Bool = false ) throws { name = vmDir.name config = try VMConfig.init(fromURL: vmDir.configURL) @@ -73,7 +75,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { clipboard: clipboard, sync: sync, caching: caching, - noTrackpad: noTrackpad + noTrackpad: noTrackpad, + noPointer: noPointer, + noKeyboard: noKeyboard ) virtualMachine = VZVirtualMachine(configuration: configuration) @@ -316,7 +320,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { clipboard: Bool = true, sync: VZDiskImageSynchronizationMode = .full, caching: VZDiskImageCachingMode? = nil, - noTrackpad: Bool = false + noTrackpad: Bool = false, + noPointer: Bool = false, + noKeyboard: Bool = false ) throws -> VZVirtualMachineConfiguration { let configuration = VZVirtualMachineConfiguration() @@ -356,8 +362,16 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { configuration.keyboards = platformSuspendable.keyboardsSuspendable() configuration.pointingDevices = platformSuspendable.pointingDevicesSuspendable() } else { - configuration.keyboards = vmConfig.platform.keyboards() - if noTrackpad { + + if noKeyboard { + configuration.keyboards = [] + } else { + configuration.keyboards = vmConfig.platform.keyboards() + } + + if noPointer { + configuration.pointingDevices = [] + } else if noTrackpad { configuration.pointingDevices = vmConfig.platform.pointingDevicesSimplified() } else { configuration.pointingDevices = vmConfig.platform.pointingDevices() From 902b1a6c9c23b7b920bdeafca5a4defd91e94519 Mon Sep 17 00:00:00 2001 From: Nikolay Edigaryev Date: Fri, 10 Oct 2025 00:55:59 +0200 Subject: [PATCH 15/20] Fix integration tests (#1149) * Use ghcr.io/cirruslabs/macos-tahoe-base:latest * CI: "Test on Sequoia" can be named just "Test" * integration-test: can use latest requests now that the bug is fixed --- .cirrus.yml | 2 +- integration-tests/requirements.txt | 2 +- integration-tests/test_run.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.cirrus.yml b/.cirrus.yml index a9f6025..bb366c6 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -1,7 +1,7 @@ use_compute_credits: true task: - name: Test on Sequoia + name: Test alias: test persistent_worker: labels: diff --git a/integration-tests/requirements.txt b/integration-tests/requirements.txt index 6695cd7..c04db34 100644 --- a/integration-tests/requirements.txt +++ b/integration-tests/requirements.txt @@ -1,6 +1,6 @@ pytest testcontainers -requests == 2.31.0 # work around https://github.com/psf/requests/issues/6707 +requests bitmath pytest-dependency paramiko diff --git a/integration-tests/test_run.py b/integration-tests/test_run.py index f7d7b75..b5f43b0 100644 --- a/integration-tests/test_run.py +++ b/integration-tests/test_run.py @@ -9,7 +9,7 @@ def test_run(tart, run_opts): vm_name = f"integration-test-run-{uuid.uuid4()}" # Instantiate a VM with admin:admin SSH access - tart.run(["clone", "ghcr.io/cirruslabs/macos-sonoma-base:latest", vm_name]) + tart.run(["clone", "ghcr.io/cirruslabs/macos-tahoe-base:latest", vm_name]) # Run the VM asynchronously tart_run_process = tart.run_async(["run", vm_name] + run_opts) From 1b091e9db03cf7c7be380eba88ecb3a610203a31 Mon Sep 17 00:00:00 2001 From: Nikolay Edigaryev Date: Tue, 21 Oct 2025 19:14:43 +0200 Subject: [PATCH 16/20] tart run: introduce new "--net-softnet-block" command-line argument (#1156) --- Sources/tart/Commands/Run.swift | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index 2af1783..8ca8f01 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -202,15 +202,28 @@ struct Run: AsyncParsableCommand { """)) var netSoftnet: Bool = false - @Option(help: ArgumentHelp("Comma-separated list of CIDRs to allow the traffic to when using Softnet isolation\n(e.g. --net-softnet-allow=192.168.0.0/24)", discussion: """ + @Option(help: ArgumentHelp("Comma-separated list of CIDRs to allow the traffic to when using Softnet isolation (e.g. --net-softnet-allow=192.168.0.0/24)", discussion: """ This option allows you bypass the private IPv4 address space restrictions imposed by --net-softnet. - For example, you can allow the VM to communicate with the local network with e.g. --net-softnet-allow=10.0.0.0/16 or to completely disable the destination based restrictions with --net-softnet-allow=0.0.0.0/0. + For example, you can allow the VM to communicate with the local network with e.g. --net-softnet-allow=10.0.0.0/16 or with --net-softnet-allow=0.0.0.0/0 to completely disable the destination based restrictions, including VMs bridge isolation. + + When used with --net-softnet-block, the longest prefix match always wins. In case the same prefix is both allowed and blocked, blocking takes precedence. Implies --net-softnet. """, valueName: "comma-separated CIDRs")) var netSoftnetAllow: String? + @Option(help: ArgumentHelp("Comma-separated list of CIDRs to block the traffic to when using Softnet isolation (e.g. --net-softnet-block=66.66.0.0/16)", discussion: """ + This option allows you to tighten the IPv4 address space restrictions imposed by --net-softnet even further. + + For example --net-softnet-block=0.0.0.0/0 may be used to establish a default deny policy that is further relaxed with --net-softnet-allow. + + When used with --net-softnet-allow, the longest prefix match always wins. In case the same prefix is both allowed and blocked, blocking takes precedence. + + Implies --net-softnet. + """, valueName: "comma-separated CIDRs")) + var netSoftnetBlock: String? + @Option(help: ArgumentHelp("Comma-separated list of TCP ports to expose (e.g. --net-softnet-expose 2222:22,8080:80)", discussion: """ Options are comma-separated and are as follows: @@ -278,7 +291,7 @@ struct Run: AsyncParsableCommand { } // Automatically enable --net-softnet when any of its related options are specified - if netSoftnetAllow != nil || netSoftnetExpose != nil { + if netSoftnetAllow != nil || netSoftnetBlock != nil || netSoftnetExpose != nil { netSoftnet = true } @@ -610,6 +623,10 @@ struct Run: AsyncParsableCommand { softnetExtraArguments += ["--allow", netSoftnetAllow] } + if let netSoftnetBlock = netSoftnetBlock { + softnetExtraArguments += ["--block", netSoftnetBlock] + } + if let netSoftnetExpose = netSoftnetExpose { softnetExtraArguments += ["--expose", netSoftnetExpose] } From 68ffa6c5e49eff0bf83b56c28093220eafd07214 Mon Sep 17 00:00:00 2001 From: Nikolay Edigaryev Date: Wed, 22 Oct 2025 03:35:42 +0200 Subject: [PATCH 17/20] tart set: support optional "pt" and "px" units for "--display" argument (#1155) * tart set: support optional "pt" and "px" units for "--display" argument * Don't forget to update "unit" too --- Sources/tart/Commands/Set.swift | 17 +++++++++++++++-- Sources/tart/Platform/Darwin.swift | 2 +- Sources/tart/VMConfig.swift | 14 ++++++++++++-- Tests/TartTests/VMConfigTests.swift | 18 ++++++++++++++++++ 4 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 Tests/TartTests/VMConfigTests.swift diff --git a/Sources/tart/Commands/Set.swift b/Sources/tart/Commands/Set.swift index fbb55d6..384fda8 100644 --- a/Sources/tart/Commands/Set.swift +++ b/Sources/tart/Commands/Set.swift @@ -14,7 +14,7 @@ struct Set: AsyncParsableCommand { @Option(help: "VM memory size in megabytes") var memory: UInt64? - @Option(help: "VM display resolution in a format of x. For example, 1200x800") + @Option(help: "VM display resolution in a format of WIDTHxHEIGHT[pt|px]. For example, 1200x800, 1200x800pt or 1920x1080px. Units are treated as hints and default to \"pt\" (points) for macOS VMs and \"px\" (pixels) for Linux VMs when not specified.") var display: VMDisplayConfig? @Flag(inversion: .prefixedNo, help: ArgumentHelp("Whether to automatically reconfigure the VM's display to fit the window")) @@ -56,6 +56,7 @@ struct Set: AsyncParsableCommand { if (display.height > 0) { vmConfig.display.height = display.height } + vmConfig.display.unit = display.unit } vmConfig.displayRefit = displayRefit @@ -88,12 +89,24 @@ struct Set: AsyncParsableCommand { extension VMDisplayConfig: ExpressibleByArgument { public init(argument: String) { + var argument = argument + var unit: Unit? = nil + + if argument.hasSuffix(Unit.pixel.rawValue) { + argument = String(argument.dropLast(Unit.pixel.rawValue.count)) + unit = Unit.pixel + } else if argument.hasSuffix(Unit.point.rawValue) { + argument = String(argument.dropLast(Unit.point.rawValue.count)) + unit = Unit.point + } + let parts = argument.components(separatedBy: "x").map { Int($0) ?? 0 } self = VMDisplayConfig( width: parts[safe: 0] ?? 0, - height: parts[safe: 1] ?? 0 + height: parts[safe: 1] ?? 0, + unit: unit, ) } } diff --git a/Sources/tart/Platform/Darwin.swift b/Sources/tart/Platform/Darwin.swift index 2aeb2a7..dcdca09 100644 --- a/Sources/tart/Platform/Darwin.swift +++ b/Sources/tart/Platform/Darwin.swift @@ -82,7 +82,7 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible { func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration { let result = VZMacGraphicsDeviceConfiguration() - if let hostMainScreen = NSScreen.main { + if (vmConfig.display.unit ?? .point) == .point, let hostMainScreen = NSScreen.main { let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height) result.displays = [ VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize) diff --git a/Sources/tart/VMConfig.swift b/Sources/tart/VMConfig.swift index 198cb24..c6e9ba9 100644 --- a/Sources/tart/VMConfig.swift +++ b/Sources/tart/VMConfig.swift @@ -32,14 +32,24 @@ enum CodingKeys: String, CodingKey { case hardwareModel } -struct VMDisplayConfig: Codable { +struct VMDisplayConfig: Codable, Equatable { + enum Unit: String, Codable { + case point = "pt" + case pixel = "px" + } + var width: Int = 1024 var height: Int = 768 + var unit: Unit? } extension VMDisplayConfig: CustomStringConvertible { var description: String { - "\(width)x\(height)" + if let unit { + "\(width)x\(height)\(unit.rawValue)" + } else { + "\(width)x\(height)" + } } } diff --git a/Tests/TartTests/VMConfigTests.swift b/Tests/TartTests/VMConfigTests.swift new file mode 100644 index 0000000..34fb901 --- /dev/null +++ b/Tests/TartTests/VMConfigTests.swift @@ -0,0 +1,18 @@ +import XCTest +@testable import tart + +final class VMConfigTests: XCTestCase { + func testVMDisplayConfig() throws { + // Defaults units (points) + var vmDisplayConfig = VMDisplayConfig.init(argument: "1234x5678") + XCTAssertEqual(VMDisplayConfig(width: 1234, height: 5678, unit: nil), vmDisplayConfig) + + // Explicit units (points) + vmDisplayConfig = VMDisplayConfig.init(argument: "1234x5678pt") + XCTAssertEqual(VMDisplayConfig(width: 1234, height: 5678, unit: .point), vmDisplayConfig) + + // Explicit units (pixels) + vmDisplayConfig = VMDisplayConfig.init(argument: "1234x5678px") + XCTAssertEqual(VMDisplayConfig(width: 1234, height: 5678, unit: .pixel), vmDisplayConfig) + } +} From e28d9337a5583311e5e09a008713ff5f0cc22702 Mon Sep 17 00:00:00 2001 From: Fedor Korotkov Date: Mon, 27 Oct 2025 11:21:46 -0400 Subject: [PATCH 18/20] [docs] publish press release about licensing violation (#1158) * [docs] publish press release about licensing violation Highlighting that this is an exceptional case since the company did contact us about a license, and we explicitly declined due to conflict of interests. * Fixed linting --- ...25-10-27-press-release-fair-enforcement.md | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/blog/posts/2025-10-27-press-release-fair-enforcement.md diff --git a/docs/blog/posts/2025-10-27-press-release-fair-enforcement.md b/docs/blog/posts/2025-10-27-press-release-fair-enforcement.md new file mode 100644 index 0000000..8f25e0c --- /dev/null +++ b/docs/blog/posts/2025-10-27-press-release-fair-enforcement.md @@ -0,0 +1,44 @@ +--- +draft: false +date: 2025-10-27 +search: + exclude: true +authors: + - fkorotkov +categories: + - announcement +--- + +# Press Release: Cirrus Labs Successfully Enforces Its Fair Source License + +**New York City, NY – October 27th, 2025 – Cirrus Labs, Inc.**, a leading provider of platforms for digital transformation, today announced that it has reached a settlement agreement regarding a violation of its Fair Source License. + + + +Cirrus Labs makes its Tart Virtualization Toolset, a leading virtualization toolset to build, run and manage macOS and Linux virtual machines (VMs) on Apple Silicon, +freely available on GitHub under the Fair Source License, a source-available license. Tart is used by tens of thousands of engineers at no charge within its generous free‑use limits. +Many large enterprises that need to exceed those limits support continued development through paid licenses. Cirrus Labs also uses Tart to power [Cirrus Runners](https://cirrus-runners.app/) +— a drop‑in replacement for macOS and Linux runners for GitHub Actions — offered at a fixed monthly price for unlimited usage. + +Cirrus Labs discovered that, **despite a prior licensing request that Cirrus Labs declined due to a conflict of interest**, the company used Tart in a manner that exceeded the license’s free‑use limits, +in order to create a competing product. + +After several months of negotiations, the matter was settled and a settlement payment to Cirrus Labs was agreed upon. + +!!! quote "Comment by Fedor Korotkov, CEO of Cirrus Labs" + + As a company we embrace healthy competition that ultimately benefits the end user. Most of our users have no trouble complying with our license, + and even when they need something more than our free use limits, we can almost always grant them a license that fits their needs. **This was an exceptional case.** + We are pleased to have reached this settlement, which validates our source-available licensing strategy and reinforces our commitment to protecting our company and serving our community. + +Cirrus Labs was represented in this matter by [Jordan Raphael](https://byronraphael.com/attorneys/jordan-raphael/) of Byron Raphael LLP, a boutique intellectual property law firm, +and [Heather Meeker](https://www.techlawpartners.com/heather), a well-known specialist in open source and source available licensing. + +The specific financial terms of the settlement and the identity of the counterparty remain confidential. + +**About Cirrus Labs:** Cirrus Labs, Inc. is a bootstrapped developer-infrastructure company founded in 2017. Our offerings among others include Tart and Cirrus Runners, +and our software is used by teams at category-leading companies including Atlassian, Figma, Zendesk, Sentry and many more. + +Learn more at [https://tart.run/](https://tart.run/) and [https://cirrus-runners.app/](https://cirrus-runners.app/). + +**Contact:** [hello@cirruslabs.org](mailto:hello@cirruslabs.org) From c192de20f5519acc54257f4933f071065851a2d3 Mon Sep 17 00:00:00 2001 From: Fedor Korotkov Date: Mon, 27 Oct 2025 12:04:35 -0400 Subject: [PATCH 19/20] [docs] clarify licensing request details in press release (#1159) --- docs/blog/posts/2025-10-27-press-release-fair-enforcement.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/blog/posts/2025-10-27-press-release-fair-enforcement.md b/docs/blog/posts/2025-10-27-press-release-fair-enforcement.md index 8f25e0c..df25326 100644 --- a/docs/blog/posts/2025-10-27-press-release-fair-enforcement.md +++ b/docs/blog/posts/2025-10-27-press-release-fair-enforcement.md @@ -20,7 +20,7 @@ freely available on GitHub under the Fair Source License, a source-available lic Many large enterprises that need to exceed those limits support continued development through paid licenses. Cirrus Labs also uses Tart to power [Cirrus Runners](https://cirrus-runners.app/) — a drop‑in replacement for macOS and Linux runners for GitHub Actions — offered at a fixed monthly price for unlimited usage. -Cirrus Labs discovered that, **despite a prior licensing request that Cirrus Labs declined due to a conflict of interest**, the company used Tart in a manner that exceeded the license’s free‑use limits, +Cirrus Labs discovered that, **despite a prior licensing request that was declined due to a conflict of interest**, another company used Tart in a manner that exceeded the license’s free‑use limits, in order to create a competing product. After several months of negotiations, the matter was settled and a settlement payment to Cirrus Labs was agreed upon. From 20dcfc83f289387efca4fb5d7b3b94d1b343ea46 Mon Sep 17 00:00:00 2001 From: Nikolay Edigaryev Date: Mon, 10 Nov 2025 23:50:32 +0400 Subject: [PATCH 20/20] Disable Sentry's app launch profiling (#1164) And access SentrySDK only when SENTRY_DSN is set. --- Sources/tart/Root.swift | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/Sources/tart/Root.swift b/Sources/tart/Root.swift index 477e6f9..9d98a12 100644 --- a/Sources/tart/Root.swift +++ b/Sources/tart/Root.swift @@ -70,20 +70,30 @@ struct Root: AsyncParsableCommand { HttpStatusCodeRange(min: 400, max: 400), HttpStatusCodeRange(min: 402, max: 599) ] + + // https://github.com/cirruslabs/tart/issues/1163 + options.enableAppLaunchProfiling = false + options.configureProfiling = { + $0.profileAppStarts = false + } + } + + SentrySDK.configureScope { scope in + scope.setExtra(value: ProcessInfo.processInfo.arguments, key: "Command-line arguments") + } + + // Enrich future events with Cirrus CI-specific tags + if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] { + SentrySDK.configureScope { scope in + for (key, value) in tags.split(separator: ",").compactMap({ parseCirrusSentryTag($0) }) { + scope.setTag(value: value, key: key) + } + } } } - defer { SentrySDK.flush(timeout: 2.seconds.timeInterval) } - - SentrySDK.configureScope { scope in - scope.setExtra(value: ProcessInfo.processInfo.arguments, key: "Command-line arguments") - } - - // Enrich future events with Cirrus CI-specific tags - if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] { - SentrySDK.configureScope { scope in - for (key, value) in tags.split(separator: ",").compactMap({ parseCirrusSentryTag($0) }) { - scope.setTag(value: value, key: key) - } + defer { + if ProcessInfo.processInfo.environment["SENTRY_DSN"] != nil { + SentrySDK.flush(timeout: 2.seconds.timeInterval) } } @@ -109,8 +119,10 @@ struct Root: AsyncParsableCommand { } // Capture the error into Sentry - SentrySDK.capture(error: error) - SentrySDK.flush(timeout: 2.seconds.timeInterval) + if ProcessInfo.processInfo.environment["SENTRY_DSN"] != nil { + SentrySDK.capture(error: error) + SentrySDK.flush(timeout: 2.seconds.timeInterval) + } // Handle a non-ArgumentParser's exception that requires a specific exit code to be set if let errorWithExitCode = error as? HasExitCode {