Compare commits

..
Author SHA1 Message Date
Nikolay Edigaryev 4e0dbcea4b integration-tests: use HttpWaitStrategy 2026-05-13 14:09:24 +02:00
Sean McLoughlin 3f1624e31a fix(control-socket): use .path instead of .path() for stale socket cleanup
Use URL's .path property instead of .path() method when removing stale
control sockets in ControlSocket.run(). The .path() method returns only
the relative component ('control.sock') while .path resolves to the
full absolute path. Since the cwd change happens after the cleanup,
removeItem silently failed to find the socket.

Add unit tests verifying controlSocketURL resolves correctly for both
absolute cleanup and relative binding use cases.

Fixes: https://github.com/cirruslabs/tart/issues/1220
2026-05-12 23:54:42 +02:00
Nikolay Edigaryev 5287b597a1 docs: clarify that nested virtualization is only for Linux VMs for now (#1233) 2026-05-12 21:45:18 +00:00
Fedor Korotkov 8aa377b71e Skip integration test gate for release (#1229) 2026-04-11 22:33:36 -04:00
Fedor Korotkov 1e52e17c21 Move brew completions to post_install (#1227)
* Move brew completions to post_install

* Reduce Layerizer test disk fixture size to 1GB

* Skip registry integration tests on Docker startup failure
2026-04-11 22:26:52 -04:00
Nikolay Edigaryev d39f7c6036 Docker-related fixes (#1221)
* tests: fix RegistryRunner's "-p" specification passed to "docker"

* tests: "docker" binary is now installed from Homebrew
2026-04-09 21:00:36 -07:00
Fedor Korotkov abfbb10618 [docs] Add announcement about joining OpenAI (#1223) 2026-04-07 03:55:20 -07:00
Nikolay Edigaryev 094f850046 Add Liquid Glass icon and sign the whole app bundle (#1216) 2026-03-20 23:19:21 +01:00
Fedor Korotkov f1305dc083 Update FAQ for local network prompt (#1211)
* Update FAQ for local network prompt

* Apply suggestions from code review
2026-03-06 17:57:06 +00:00
Nikolay Edigaryev 605234b5dd Mention macOS Tahoe everywhere instead of macOS Sequoia (#1208)
* Mention macOS Tahoe everywhere instead of macOS Sequoia

* Fix spurious rename
2026-03-02 08:50:29 -05:00
sneedandfeed be272d8abd Replace Sequoia with Tahoe in Quick Start's first few instructions & add Tahoe to images available (#1206)
* update quick-start.md for tahoe

* oops

* I forgot this part.
2026-02-27 08:27:59 -05:00
faa40b6832 Remove disk v1 support (#1204)
* Remove disk v1 support

* fix: address PR review feedback

- add explicit error for legacy disk.v1 media type during pull
- include actionable re-push guidance in runtime error

🤖 Generated with [Codex](https://chatgpt.com/codex)

Co-Authored-By: Codex <codex@openai.com>

* Re-use legacyDiskV1MediaType in error message

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>
2026-02-25 14:34:25 +00:00
Nikolay Edigaryev d45ef38cf7 StdinCredentials: increase maxCharacters to 8,192 (#1203) 2026-02-23 18:55:52 +01:00
Nikolay Edigaryev e26b376d51 tart list: remove "SizeOnDisk" and add "Accessed" field (#1202)
* tart list: introduce "Accessed" field to show last accessed date of a VM

* tart list: remove "SizeOnDisk" field as it's unused
2026-02-23 18:55:36 +01:00
Nikolay Edigaryev 8f8a24ad19 Use ghcr.io/squidfunk/mkdocs-material:latest container for docs (#1201)
* Use ghcr.io/squidfunk/mkdocs-material:latest container for docs

* CI: use ghcr.io/squidfunk/mkdocs-material:latest too
2026-02-17 14:59:25 -05:00
Fedor Korotkov 29e0606ea3 Update yearly pricing docs (#1197)
* Update yearly pricing docs

* fix: clarify pricing update in 2023 licensing post
2026-02-13 15:46:01 +00:00
Nikolay Edigaryev 594c6d74cd docs: migrate "Managing VMs" section to "Quick Start" (#1196) 2026-02-13 05:18:14 -05:00
Nikolay Edigaryev fc159c9992 docs: document TART_REGISTRY_HOSTNAME (#1195) 2026-02-12 21:56:24 +00:00
Nikolay Edigaryev 863e3c2925 Bind and connect to Unix domain sockets using relative paths (#1192) 2026-02-05 15:51:14 +01:00
Nikolay Edigaryev 372affb0dc Switch back to github.com/open-telemetry/opentelemetry-swift upstream (#1189) 2026-02-02 19:40:24 +01:00
Nikolay Edigaryev 37b8219579 Switch to github.com/open-telemetry/opentelemetry-swift fork (#1186)
* Switch to github.com/open-telemetry/opentelemetry-swift fork

* Use cirruslabs-owned fork
2026-01-29 16:54:57 +00:00
Nikolay Edigaryev f1aa591935 OpenTelemetry: set default resources, service.name and service.version (#1184)
* OpenTelemetry: set default resources, service.name and service.version

* Ensure that service name and version resources are set
2026-01-27 16:17:05 +01:00
Fedor KorotkovandClaude Opus 4.5 6189dc23af Fix VM window not appearing on tart run (#1183)
Restore the applicationDidFinishLaunching method that was accidentally
removed in commit b1e88e1 ("tart run: do not remove 'Edit' menu as its
not present anymore").

That commit intended to remove the Edit menu removal code (since the
menu no longer exists), but also removed the crucial activation code:
- setActivationPolicy(.regular) - tells macOS this is a GUI app
- activate(ignoringOtherApps:) - brings the window to the foreground

Without these calls, the VM runs fine (SSH works) but no window appears
on screen.

Fixes #1181

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 15:16:33 -05:00
Fedor Korotkov 361465748b Add build workflow for testing (#1180)
* Add build workflow

* Split build workflows

* Combine build workflows
2026-01-23 09:41:13 -05:00
Nikolay Edigaryev e0147448a8 OpenTelemetry: only initialize tracing when TRACEPARENT env. var. is set (#1182) 2026-01-23 14:57:53 +01:00
Nikolay Edigaryev 7038c45f8b Switch to OpenTelemetry (#1179)
* Switch to OpenTelemetry

* Integration tests in Golang
2026-01-23 12:04:21 +01:00
Nikolay Edigaryev 44892c5def Refactor "diskutil create" and "diskutil info" into a separate class (#1172)
* Show true ASIF disk sizes

* Use older sizeGB()
2026-01-22 13:00:27 +01:00
Nikolay Edigaryev 20dcfc83f2 Disable Sentry's app launch profiling (#1164)
And access SentrySDK only when SENTRY_DSN is set.
2025-11-10 23:50:32 +04:00
Fedor Korotkov c192de20f5 [docs] clarify licensing request details in press release (#1159) 2025-10-27 16:04:35 +00:00
Fedor Korotkov e28d9337a5 [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
2025-10-27 11:21:46 -04:00
Nikolay Edigaryev 68ffa6c5e4 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
2025-10-21 21:35:42 -04:00
Nikolay Edigaryev 1b091e9db0 tart run: introduce new "--net-softnet-block" command-line argument (#1156) 2025-10-21 21:14:43 +04:00
Nikolay Edigaryev 902b1a6c9c 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
2025-10-09 18:55:59 -04:00
Eric Kolve 90d9500133 chore: adding no-keyboard, no-pointer options for run (#1091) 2025-10-09 15:29:28 -04:00
Nikolay Edigaryev b05c731510 FAQ: document creation and unlocking of the keychain headless machines (#1148)
* FAQ: document creation and unlocking of the keychain headless machines

* Remove extra spaces

* Fix typo: this commands → this command
2025-10-08 22:19:02 +04:00
Nikolay Edigaryev d762fe6fc1 tart run: do not recommend running "tart run" as root (#1147) 2025-10-08 12:44:05 +00:00
Stefan MitterrutznerandNikolay Edigaryev eff964b62a 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 <edigaryev@gmail.com>

---------

Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>
2025-09-29 15:57:39 +04:00
fsc-eriker 590e064e35 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
2025-09-29 07:43:48 -04:00
fsc-eriker 839c6e7562 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.
2025-09-29 07:43:14 -04:00
Nikolay Edigaryev e3ee2da2fd 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"
2025-09-25 20:44:57 +04:00
Nikolay Edigaryev 84147f29b5 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
2025-09-23 00:02:39 +04:00
jxlwqqandjinxiaolong a655edd826 docs: update sshpass command to ignore known hosts file (#1136)
Co-authored-by: jinxiaolong <jinxiaolong@tuhu.cn>
2025-09-22 23:12:45 +04:00
Nikolay Edigaryev df100f1ca2 Improve credential provider errors (#1133) 2025-09-22 22:57:05 +04:00
Fedor Korotkov 02bf5651e7 tart clone: make pruning limit configurable (#1126)
* tart clone: make pruning limit configurable

* Fixed compilation
2025-09-14 12:38:57 -04:00
Fedor Korotkov 96c89ad76e tart clone: cap automatic pruning at 100 GB (#1124) 2025-09-14 09:40:58 -04:00
Nikolay Edigaryev b78fa6ba1c 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+
2025-09-14 09:40:06 -04:00
Nikolay Edigaryev e443cfa9a2 tart exec: do not attempt to call TTY-related methods when no -t is set (#1122) 2025-09-12 19:17:17 +04:00
Nikolay Edigaryev e35c13425e tart exec: handle input redirection of regular files (#1106) 2025-07-14 19:49:12 +04:00
Nikolay Edigaryev 0debec1266 docs: include full article content in RSS (#1104) 2025-07-08 21:06:37 +04:00
Nikolay Edigaryev 294c5fc5e5 Upgrade Swift Argument Parser to 1.6.1 (#1103)
* Upgrade Swift Argument Parser to 1.6.1

* Remove ArgumentParser workaround
2025-07-08 00:19:50 +04:00
Fedor Korotkov 99777b6740 Update README example to use macOS Tahoe (#1101) 2025-07-07 13:50:16 +04:00
Fedor KorotkovandClaude a2972aa4d9 feat: prioritize pruning of old SHA when pulling updated tags (#1102)
* feat: prioritize pruning of old SHA when pulling updated tags

When pulling a new version of a tagged image (e.g., ghcr.io/cirruslabs/macos-runner:sonoma),
set the access date of the previous SHA to epoch time (1970-01-01). This ensures that the
old SHA will be prioritized for pruning, even if it was accessed more recently than other
cached images.

This helps manage disk space more efficiently by automatically cleaning up superseded
versions of frequently-updated tagged images.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* format

* Review comments

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-07-07 08:52:05 +00:00
Fedor KorotkovandNikolay Edigaryev 3a6c5fb81d feat: Add disk image format selection with ASIF support (#1094)
* feat: Add disk image format selection with ASIF support

* fixed goreleaser-pro

* Fix ASIF disk format compatibility issues

- Use .uncached caching mode for ASIF disks to avoid Virtualization framework compatibility issues
- Improve caching mode selection logic for better maintainability
- Fix compiler warning by changing var to let for attachment variable

This resolves VM startup failures when using ASIF disk format by ensuring proper disk attachment configuration.

* Update goreleaser installation to use tap-specific formula

Change from 'brew install --cask goreleaser-pro' to 'brew install --cask goreleaser/tap/goreleaser-pro' for proper installation from the official goreleaser tap.

* Remove VS Code configuration and add to gitignore

- Remove .vscode/launch.json from repository
- Add .vscode/ to .gitignore to prevent VS Code settings from being tracked

* Implement ASIF disk resize using diskutil

- Add support for resizing ASIF disk images using diskutil image resize
- Detect disk format from VM config and route to appropriate resize method
- Use diskutil image info to get current ASIF disk size and validate resize
- Remove restriction that prevented ASIF disk resizing in Set command
- Add FailedToResizeDisk error case for proper error handling
- Maintain backward compatibility with raw disk resizing
- Add comprehensive size validation to prevent data loss

* Update Sources/tart/Commands/Create.swift

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

* Update Sources/tart/DiskImageFormat.swift

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

* Update Sources/tart/DiskImageFormat.swift

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

* Fix test naming and remove redundant test cases

- Rename testFormatArgument to testCaseInsensitivity for clarity
- Remove redundant 'raw' and 'invalid' test cases already covered in testFormatFromString
- Remove testFormatDescriptions test as it's not very useful

Addresses review comment: https://github.com/cirruslabs/tart/pull/1094#discussion_r2152093510

* Remove canCreate property and simplify DiskImageFormat

- Remove canCreate property since it's the same as isSupported
- Remove description property entirely as it's not used
- Fix displayName for RAW format (remove UDIF reference)
- Remove checkDiskutilASIFSupport helper function

Addresses review comments:
- https://github.com/cirruslabs/tart/pull/1094#discussion_r2152109450
- https://github.com/cirruslabs/tart/pull/1094#discussion_r2152115610
- https://github.com/cirruslabs/tart/pull/1094#discussion_r2152124330

* Update Create command validation and help text

- Simplify ArgumentParser help text to let it show possible values automatically
- Remove canCreate validation since property was removed
- Simplify error message for unsupported disk formats

Addresses review comment: https://github.com/cirruslabs/tart/pull/1094#discussion_r2152113480

* Add disk format validation to Run command

- Add validation to ensure ASIF disk format is supported on current system
- Check disk format compatibility before attempting to run VM

Addresses review comment: https://github.com/cirruslabs/tart/pull/1094#discussion_r2152109450

* Use proper namespaced constant for OCI label

- Add diskFormatLabelAnnotation constant in Manifest.swift
- Use org.cirruslabs.tart.disk.format namespace for consistency
- Use variable shadowing instead of new variable name for labels

Addresses review comment: https://github.com/cirruslabs/tart/pull/1094#discussion_r2152163515

* Remove special ASIF caching mode

- Remove .uncached caching mode for ASIF disks
- Use default caching logic for all disk formats
- Testing shows .cached mode works fine on macOS 26.0

Addresses review comment: https://github.com/cirruslabs/tart/pull/1094#discussion_r2152133589

* Improve code structure in VMDirectory

- Use guard let instead of nested if let for better readability
- Reduce nesting in resizeASIFDisk function
- Improve error handling flow

Addresses review comment: https://github.com/cirruslabs/tart/pull/1094#discussion_r2152141916

* diskFormatLabel

* reverted caching mode

* Use PropertyListDecoder

---------

Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>
2025-06-19 18:27:30 +04:00
Nikolay Edigaryev 5793935317 tart ip: implement --resolver=agent (#1095)
* tart ip: implement --resolver=agent

* CI: fix GoReleaser installation
2025-06-19 13:07:06 +04:00
Nikolay Edigaryev 8dc8b644b2 tart exec: do not limit RPC call duration to 1 second 2025-06-11 20:15:18 +02:00
Nikolay Edigaryev b625c04131 tart exec: make sure <name> goes after flags like -i and -t in --help (#1090) 2025-06-11 21:58:30 +04:00
Nikolay Edigaryev a0c03dcce6 docs: new "Bridging the gaps with the Tart Guest Agent" blog post (#1080) 2025-06-01 19:54:45 -04:00
Nikolay Edigaryev 8539b8faae Delay Sentry initialization until after we parse the CLI arguments (#1085) 2025-05-30 17:24:19 +04:00
Nikolay Edigaryev 71159373e5 tart run: allow "--dir" with "--suspendable" (#1082) 2025-05-30 17:24:09 +04:00
Fedor Korotkov 8248f19943 Update sentry (#1079) 2025-05-28 23:06:10 +00:00
fedor 1cbc1e2cda Suspendable VMs now support consoles 2025-05-28 17:09:54 -04:00
Nikolay Edigaryev 0187834c34 tart exec: explain that Tart Guest Agent is required (#1078)
* tart exec: explain that Tart Guest Agent is required

Also handle decrease the connection timeout to 1 second
and provide a hint to the user.

* execute() can be made private

* Include error.localizedDescription
2025-05-27 12:57:56 +04:00
Nikolay Edigaryev dfbdb5559c Introduce "tart exec" command as an alternative to SSH (#1074)
* Introduce "tart exec" command as an alternative to SSH

* Simplify control socket machinery by using NIO async/await primitives

* No reason to print the "vm" object directly, just refer to it as "VM"

* Log to Apple’s Unified Logging System
2025-05-22 17:28:14 +04:00
Nikolay Edigaryev 40ab5c3af4 Fix unescaped commas in generated ArgumentParser completions (#1066)
* Fix unescaped commas in generated ArgumentParser completions

* Improve completion hints
2025-05-06 14:24:43 +04:00
fedor 280a31f707 Update docs, examples and CI to Sequoia 2025-05-04 20:49:23 -04:00
Nikolay EdigaryevandFedor Korotkov 8d49404337 Enable clipboard sharing on macOS too (#1046)
* Enable clipboard sharing on macOS too

And document which packages need to be installed on these operating
systems.

* We now use Tart Guest Agent

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>

---------

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>
2025-04-29 21:15:03 +04:00
Nikolay EdigaryevandCopilot 64a3999a58 Improve Orchard docs (#1064)
* Iterate over Orchard Architecture description

* Document Orchard Controller customization (e.g. --listen-ssh)

* New section: "Using Orchard CLI"

* Fix Markdown unordered list indentation

* Fix "fenced code blocks should have a language specified"

* the context → a context

* Clarify different port

* Simplify labels explanation

* Studios → Studio

* Better explain resources

* crate → create

* only to place → only place

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Surround "Using resources when creating VMs" header by blank lines

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-29 18:10:14 +04:00
Fedor Korotkov 5c1f5a61c1 Add --no-trackpad option to disable trackpad on macOS VMs (#1060)
* Add --no-trackpad option to disable trackpad on macOS VMs

* Cleanup after AI
2025-04-22 10:17:19 -04:00
Fedor Korotkov 1310220f05 Add NSLocalNetworkUsageDescription (#1058) 2025-04-18 18:27:32 +04:00
dependabot[bot] 1fe2f1ff88 Bump golang.org/x/crypto from 0.21.0 to 0.35.0 in /benchmark (#1057)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.21.0 to 0.35.0.
- [Commits](https://github.com/golang/crypto/compare/v0.21.0...v0.35.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.35.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-18 18:01:55 +04:00
df3de33f1a Posibility to add Labels when pushing OCI Image (#1052)
* Posibility to add Labels when pushing OCI Image

Example running:
tart push $image ${registry}/org/${image}-testing --labels com.org.revision=testing --labels com.org.repo.buildid=123456

* Fix Linting

Run swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore

* Update Sources/tart/Commands/Push.swift

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

* Update Sources/tart/Commands/Push.swift

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

* Update Sources/tart/Commands/Push.swift

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

* Update Sources/tart/OCI/Manifest.swift

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

* Update Sources/tart/Commands/Push.swift

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

* Update Sources/tart/Commands/Push.swift

* Do not use a variable to store parseLabels() results

* Trim spaces before splitting labels and support empty values

---------

Co-authored-by: Victor Serbu <victors@4psa.com>
Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>
Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>
2025-04-14 16:10:12 +00:00
Fedor Korotkov 1560e4d312 Update Orchard Worker Instructions (#1055)
To include workaround for https://github.com/cirruslabs/orchard/pull/302
2025-04-14 14:21:28 +00:00
Samuel 318202fa81 fix: correct typo in validation error message for nested virtualization support (#1050) 2025-04-04 11:20:43 -04:00
Fedor Korotkov cb92a3fa67 Use full host resources for Xcode benchmarks (#1045) 2025-03-23 16:55:32 -04:00
Nikolay Edigaryev 9c30638079 docs(FAQ): document disk resizing procedure (#1042) 2025-03-18 21:36:30 +04:00
122 changed files with 2769 additions and 633 deletions
+13 -9
View File
@@ -1,7 +1,7 @@
use_compute_credits: true use_compute_credits: true
task: task:
name: Test on Sonoma name: Test
alias: test alias: test
persistent_worker: persistent_worker:
labels: labels:
@@ -11,6 +11,8 @@ task:
build_script: build_script:
- swift build - swift build
test_script: test_script:
# Add /usr/sbin to PATH, otherwise testDiskutilInfo() fails to locate "diskutil"
- export PATH=$PATH:/usr/sbin
- swift test - swift test
integration_test_script: integration_test_script:
- codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart - codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
@@ -21,6 +23,7 @@ task:
- source venv/bin/activate - source venv/bin/activate
- pip install -r requirements.txt - pip install -r requirements.txt
- pytest --verbose --junit-xml=pytest-junit.xml - pytest --verbose --junit-xml=pytest-junit.xml
- go test -v ./...
pytest_junit_result_artifacts: pytest_junit_result_artifacts:
path: "integration-tests/pytest-junit.xml" path: "integration-tests/pytest-junit.xml"
format: junit format: junit
@@ -37,7 +40,7 @@ task:
name: Lint name: Lint
alias: lint alias: lint
macos_instance: macos_instance:
image: ghcr.io/cirruslabs/macos-runner:sonoma image: ghcr.io/cirruslabs/macos-runner:tahoe
lint_script: lint_script:
- swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json . - swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json .
always: always:
@@ -54,7 +57,7 @@ task:
name: Build ($BUILD_ARCH) name: Build ($BUILD_ARCH)
alias: build alias: build
macos_instance: macos_instance:
image: ghcr.io/cirruslabs/macos-runner:sonoma image: ghcr.io/cirruslabs/macos-runner:tahoe
build_script: swift build --arch $BUILD_ARCH --product tart build_script: swift build --arch $BUILD_ARCH --product tart
sign_script: codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/$BUILD_ARCH-apple-macosx/debug/tart sign_script: codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/$BUILD_ARCH-apple-macosx/debug/tart
binary_artifacts: binary_artifacts:
@@ -67,7 +70,7 @@ task:
- lint - lint
- build - build
macos_instance: macos_instance:
image: ghcr.io/cirruslabs/macos-runner:sonoma image: ghcr.io/cirruslabs/macos-runner:tahoe
env: env:
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2] MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2] AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
@@ -83,8 +86,9 @@ task:
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD - xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
install_script: install_script:
- brew install go goreleaser/tap/goreleaser-pro - brew install go
- brew install mitchellh/gon/gon - brew install mitchellh/gon/gon
- brew install --cask goreleaser/tap/goreleaser-pro
info_script: info_script:
- security find-identity -v - security find-identity -v
- xcodebuild -version - xcodebuild -version
@@ -99,10 +103,9 @@ task:
only_if: $CIRRUS_TAG != '' only_if: $CIRRUS_TAG != ''
depends_on: depends_on:
- lint - lint
- test
- build - build
macos_instance: macos_instance:
image: ghcr.io/cirruslabs/macos-runner:sonoma image: ghcr.io/cirruslabs/macos-runner:tahoe
env: env:
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2] MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2] AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
@@ -121,8 +124,9 @@ task:
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD - xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
install_script: install_script:
- brew install go goreleaser/tap/goreleaser-pro getsentry/tools/sentry-cli - brew install go getsentry/tools/sentry-cli
- brew install mitchellh/gon/gon - brew install mitchellh/gon/gon
- brew install --cask goreleaser/tap/goreleaser-pro
info_script: info_script:
- security find-identity -v - security find-identity -v
- xcodebuild -version - xcodebuild -version
@@ -148,7 +152,7 @@ task:
name: Deploy Documentation name: Deploy Documentation
only_if: $CIRRUS_BRANCH == 'main' only_if: $CIRRUS_BRANCH == 'main'
container: container:
image: ghcr.io/cirruslabs/mkdocs-material-insiders:latest image: ghcr.io/squidfunk/mkdocs-material:latest
registry_config: ENCRYPTED[!cf1a0f25325aa75bad3ce6ebc890bc53eb0044c02efa70d8cefb83ba9766275a994b4831706c52630a0692b2fa9cfb9e!] registry_config: ENCRYPTED[!cf1a0f25325aa75bad3ce6ebc890bc53eb0044c02efa70d8cefb83ba9766275a994b4831706c52630a0692b2fa9cfb9e!]
env: env:
DEPLOY_TOKEN: ENCRYPTED[!45ed45666558902ed1c2400add734ec063103bec31841847e8c8764802fca229bfa6d85c690e16ad159e047574b48793!] DEPLOY_TOKEN: ENCRYPTED[!45ed45666558902ed1c2400add734ec063103bec31841847e8c8764802fca229bfa6d85c690e16ad159e047574b48793!]
+5
View File
@@ -4,3 +4,8 @@ root = true
indent_style = space indent_style = space
indent_size = 2 indent_size = 2
insert_final_newline = true insert_final_newline = true
[integration-tests/**]
indent_style = unset
indent_size = unset
insert_final_newline = unset
+37
View File
@@ -0,0 +1,37 @@
name: Build
on:
workflow_dispatch:
permissions:
contents: read
jobs:
build_cached:
name: Build tart (cached)
runs-on: ghcr.io/cirruslabs/macos-runner:tahoe
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- name: Build
run: |
export COMPILATION_CACHE_ENABLE_CACHING=YES
export COMPILATION_CACHE_REMOTE_SERVICE_PATH="$HOME/.cirruslabs/omni-cache.sock"
export COMPILATION_CACHE_ENABLE_PLUGIN=YES
export COMPILATION_CACHE_ENABLE_INTEGRATED_QUERIES=YES
export COMPILATION_CACHE_ENABLE_DETACHED_KEY_QUERIES=YES
export SWIFT_ENABLE_COMPILE_CACHE=YES
export SWIFT_ENABLE_EXPLICIT_MODULES=YES
export SWIFT_USE_INTEGRATED_DRIVER=YES
export CLANG_ENABLE_COMPILE_CACHE=YES
export CLANG_ENABLE_MODULES=YES
swift build --build-system swiftbuild --product tart
build_no_cache:
name: Build tart (no cache)
runs-on: ghcr.io/cirruslabs/macos-runner:tahoe
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- name: Build
run: swift build --build-system swiftbuild --product tart
+3
View File
@@ -8,6 +8,9 @@ tart.xcodeproj/
# AppCode # AppCode
.idea/ .idea/
# VS Code
.vscode/
# Swift # Swift
.build/ .build/
+19 -11
View File
@@ -25,20 +25,26 @@ universal_binaries:
- name_template: tart.app/Contents/MacOS/tart - name_template: tart.app/Contents/MacOS/tart
replace: true replace: true
hooks: hooks:
post: gon gon.hcl post:
- mkdir -p dist/tart_darwin_all/tart.app/Contents/Resources
- cp Resources/embedded.provisionprofile dist/tart_darwin_all/tart.app/Contents/
- cp Resources/Info.plist dist/tart_darwin_all/tart.app/Contents/
- cp "Resources/actool/UPW Tart.icns" "Resources/actool/Assets.car" dist/tart_darwin_all/tart.app/Contents/Resources/
- gon gon.hcl
archives: archives:
- name_template: "{{ .ProjectName }}" - name_template: "{{ .ProjectName }}"
files: files:
- src: Resources/embedded.provisionprofile - src: dist/tart_darwin_all/tart.app/Contents/Info.plist
dst: tart.app/Contents dst: tart.app/Contents/Info.plist
strip_parent: true - src: dist/tart_darwin_all/tart.app/Contents/embedded.provisionprofile
- src: Resources/Info.plist dst: tart.app/Contents/embedded.provisionprofile
dst: tart.app/Contents - src: dist/tart_darwin_all/tart.app/Contents/Resources/UPW Tart.icns
strip_parent: true dst: tart.app/Contents/Resources/UPW Tart.icns
- src: Resources/AppIcon.png - src: dist/tart_darwin_all/tart.app/Contents/Resources/Assets.car
dst: tart.app/Contents/Resources dst: tart.app/Contents/Resources/Assets.car
strip_parent: true - src: dist/tart_darwin_all/tart.app/Contents/_CodeSignature/CodeResources
dst: tart.app/Contents/_CodeSignature/CodeResources
- LICENSE - LICENSE
release: release:
@@ -65,6 +71,8 @@ brews:
install: | install: |
libexec.install Dir["*"] libexec.install Dir["*"]
bin.write_exec_script "#{libexec}/tart.app/Contents/MacOS/tart" bin.write_exec_script "#{libexec}/tart.app/Contents/MacOS/tart"
generate_completions_from_executable(libexec/"tart.app/Contents/MacOS/tart", "--generate-completion-script")
custom_block: | custom_block: |
depends_on :macos => :ventura depends_on :macos => :ventura
def post_install
generate_completions_from_executable(libexec/"tart.app/Contents/MacOS/tart", "--generate-completion-script")
end
+1
View File
@@ -29,6 +29,7 @@ Table of Contents
1. Code should follow camel case 1. Code should follow camel case
2. Code should follow [SwiftFormat](https://github.com/nicklockwood/SwiftFormat#swift-package-manager-plugin) guidelines. You can auto-format the code by running the following command: 2. Code should follow [SwiftFormat](https://github.com/nicklockwood/SwiftFormat#swift-package-manager-plugin) guidelines. You can auto-format the code by running the following command:
```bash ```bash
swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore . swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore .
``` ```
+2 -2
View File
@@ -7,7 +7,7 @@ Perhaps, the easiest, but not the most comprehensive way to tell what's going on
In the example below, you will run `tart pull` via `time(1)` to gather generalized CPU, I/O and memory usage metrics: In the example below, you will run `tart pull` via `time(1)` to gather generalized CPU, I/O and memory usage metrics:
```shell ```shell
/usr/bin/time -l tart pull ghcr.io/cirruslabs/macos-sequoia-base:latest /usr/bin/time -l tart pull ghcr.io/cirruslabs/macos-tahoe-base:latest
``` ```
**Note:** you need to specify a full path to `time(1)` binary, otherwise the shell's built-in `time` command will be invoked, which doesn't have the `-l` command-line argument. **Note:** you need to specify a full path to `time(1)` binary, otherwise the shell's built-in `time` command will be invoked, which doesn't have the `-l` command-line argument.
@@ -48,7 +48,7 @@ To use it, make sure that [Xcode](https://developer.apple.com/xcode/resources/)
Once done, you can create a CPU profile of `tart pull`: Once done, you can create a CPU profile of `tart pull`:
```shell ```shell
xctrace record --template "CPU Profiler" --target-stdout - --launch -- /opt/homebrew/bin/tart pull ghcr.io/cirruslabs/macos-sequoia-base:latest xctrace record --template "CPU Profiler" --target-stdout - --launch -- /opt/homebrew/bin/tart pull ghcr.io/cirruslabs/macos-tahoe-base:latest
``` ```
Now that `xctrace(1)` is running, you'll see the `tart pull`-related output first, and once finished, the following line will appear: Now that `xctrace(1)` is running, you'll see the `tart pull`-related output first, and once finished, the following line will appear:
+169 -16
View File
@@ -1,5 +1,5 @@
{ {
"originHash" : "22b3726bc4e4c6e9c04ac97cb08a82967feb39960a93d2909768a16e11576748", "originHash" : "061dfe6cdf4e6dbf32b51c5e7023c4ae69726dcafb42a35b34e5489b0338c17f",
"pins" : [ "pins" : [
{ {
"identity" : "antlr4", "identity" : "antlr4",
@@ -10,6 +10,24 @@
"version" : "4.13.2" "version" : "4.13.2"
} }
}, },
{
"identity" : "cirruslabs_tart-guest-agent_apple_swift",
"kind" : "remoteSourceControl",
"location" : "https://buf.build/gen/swift/git/1.33.3-20260114140118-bd09c26a260f.1/cirruslabs_tart-guest-agent_apple_swift.git",
"state" : {
"revision" : "5c49a653f4b003161077d194bc708b7373628c99",
"version" : "1.33.3-20260114140118-bd09c26a260f.1"
}
},
{
"identity" : "cirruslabs_tart-guest-agent_grpc_swift",
"kind" : "remoteSourceControl",
"location" : "https://buf.build/gen/swift/git/1.27.1-20260114140118-bd09c26a260f.1/cirruslabs_tart-guest-agent_grpc_swift.git",
"state" : {
"branch" : "main",
"revision" : "4935078c2fe2508360843596d71a1f844ce639a6"
}
},
{ {
"identity" : "dynamic", "identity" : "dynamic",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -19,6 +37,42 @@
"revision" : "772883073d044bc754d401cabb6574624eb3778f" "revision" : "772883073d044bc754d401cabb6574624eb3778f"
} }
}, },
{
"identity" : "grpc-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/grpc/grpc-swift.git",
"state" : {
"revision" : "8f57f68b9d247fe3759fa9f18e1fe919911e6031",
"version" : "1.27.1"
}
},
{
"identity" : "opentelemetry-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/open-telemetry/opentelemetry-swift",
"state" : {
"branch" : "main",
"revision" : "ed37be9525081509ab62410d38b705c2b3f0d5a4"
}
},
{
"identity" : "opentelemetry-swift-core",
"kind" : "remoteSourceControl",
"location" : "https://github.com/open-telemetry/opentelemetry-swift-core.git",
"state" : {
"revision" : "240c8d5e36c3c7b774ed961325369f0b1f2c965f",
"version" : "2.3.0"
}
},
{
"identity" : "opentracing-objc",
"kind" : "remoteSourceControl",
"location" : "https://github.com/undefinedlabs/opentracing-objc",
"state" : {
"revision" : "18c1a35ca966236cee0c5a714a51a73ff33384c1",
"version" : "0.5.2"
}
},
{ {
"identity" : "semaphore", "identity" : "semaphore",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -28,15 +82,6 @@
"version" : "0.1.0" "version" : "0.1.0"
} }
}, },
{
"identity" : "sentry-cocoa",
"kind" : "remoteSourceControl",
"location" : "https://github.com/getsentry/sentry-cocoa",
"state" : {
"revision" : "5575af93efb776414f243e93d6af9f6258dc539a",
"version" : "8.36.0"
}
},
{ {
"identity" : "swift-algorithms", "identity" : "swift-algorithms",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -51,8 +96,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser", "location" : "https://github.com/apple/swift-argument-parser",
"state" : { "state" : {
"revision" : "41982a3656a71c768319979febd796c6fd111d5c", "revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3",
"version" : "1.5.0" "version" : "1.6.1"
} }
}, },
{ {
@@ -60,8 +105,35 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git", "location" : "https://github.com/apple/swift-atomics.git",
"state" : { "state" : {
"revision" : "cd142fd2f64be2100422d658e7411e39489da985", "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
"version" : "1.2.0" "version" : "1.3.0"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "671108c96644956dddcd89dd59c203dcdb36cec7",
"version" : "1.1.4"
}
},
{
"identity" : "swift-http-structured-headers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-structured-headers.git",
"state" : {
"revision" : "db6eea3692638a65e2124990155cd220c2915903",
"version" : "1.3.0"
}
},
{
"identity" : "swift-http-types",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-types.git",
"state" : {
"revision" : "a0a57e949a8903563aba4615869310c0ebf14c03",
"version" : "1.4.0"
} }
}, },
{ {
@@ -69,8 +141,62 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git", "location" : "https://github.com/apple/swift-log.git",
"state" : { "state" : {
"revision" : "9cb486020ebf03bfa5b5df985387a14a98744537", "revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181",
"version" : "1.6.1" "version" : "1.9.1"
}
},
{
"identity" : "swift-metrics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-metrics.git",
"state" : {
"revision" : "0743a9364382629da3bf5677b46a2c4b1ce5d2a6",
"version" : "2.7.1"
}
},
{
"identity" : "swift-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "233f61bc2cfbb22d0edeb2594da27a20d2ce514e",
"version" : "2.93.0"
}
},
{
"identity" : "swift-nio-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-extras.git",
"state" : {
"revision" : "f1f6f772198bee35d99dd145f1513d8581a54f2c",
"version" : "1.26.0"
}
},
{
"identity" : "swift-nio-http2",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-http2.git",
"state" : {
"revision" : "4281466512f63d1bd530e33f4aa6993ee7864be0",
"version" : "1.36.0"
}
},
{
"identity" : "swift-nio-ssl",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-ssl.git",
"state" : {
"revision" : "4b38f35946d00d8f6176fe58f96d83aba64b36c7",
"version" : "2.31.0"
}
},
{
"identity" : "swift-nio-transport-services",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-transport-services.git",
"state" : {
"revision" : "cd1e89816d345d2523b11c55654570acd5cd4c56",
"version" : "1.24.0"
} }
}, },
{ {
@@ -82,6 +208,15 @@
"version" : "1.0.2" "version" : "1.0.2"
} }
}, },
{
"identity" : "swift-protobuf",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git",
"state" : {
"revision" : "c169a5744230951031770e27e475ff6eefe51f9d",
"version" : "1.33.3"
}
},
{ {
"identity" : "swift-retry", "identity" : "swift-retry",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -100,6 +235,15 @@
"version" : "1.8.0" "version" : "1.8.0"
} }
}, },
{
"identity" : "swift-system",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system.git",
"state" : {
"revision" : "a34201439c74b53f0fd71ef11741af7e7caf01e1",
"version" : "1.4.2"
}
},
{ {
"identity" : "swift-xattr", "identity" : "swift-xattr",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -144,6 +288,15 @@
"branch" : "master", "branch" : "master",
"revision" : "e03289289155b4e7aa565e32862f9cb42140596a" "revision" : "e03289289155b4e7aa565e32862f9cb42140596a"
} }
},
{
"identity" : "thrift-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/undefinedlabs/Thrift-Swift",
"state" : {
"revision" : "18ff09e6b30e589ed38f90a1af23e193b8ecef8e",
"version" : "1.1.2"
}
} }
], ],
"version" : 3 "version" : 3
+12 -3
View File
@@ -10,20 +10,24 @@ let package = Package(
.executable(name: "tart", targets: ["tart"]) .executable(name: "tart", targets: ["tart"])
], ],
dependencies: [ dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.1"), .package(url: "https://github.com/apple/swift-argument-parser", from: "1.6.1"),
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"), .package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"),
.package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0"), .package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0"),
.package(url: "https://github.com/malcommac/SwiftDate", from: "7.0.0"), .package(url: "https://github.com/malcommac/SwiftDate", from: "7.0.0"),
.package(url: "https://github.com/antlr/antlr4", exact: "4.13.2"), .package(url: "https://github.com/antlr/antlr4", exact: "4.13.2"),
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.2.0")), .package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.2.0")),
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.53.6"), .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.53.6"),
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.36.0"),
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"), .package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
.package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.8.0"), .package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.8.0"),
.package(url: "https://github.com/orchetect/SwiftRadix", from: "1.3.1"), .package(url: "https://github.com/orchetect/SwiftRadix", from: "1.3.1"),
.package(url: "https://github.com/groue/Semaphore", from: "0.0.8"), .package(url: "https://github.com/groue/Semaphore", from: "0.0.8"),
.package(url: "https://github.com/fumoboy007/swift-retry", from: "0.2.3"), .package(url: "https://github.com/fumoboy007/swift-retry", from: "0.2.3"),
.package(url: "https://github.com/jozefizso/swift-xattr", from: "3.0.0"), .package(url: "https://github.com/jozefizso/swift-xattr", from: "3.0.0"),
.package(url: "https://github.com/grpc/grpc-swift.git", .upToNextMajor(from: "1.27.0")),
.package(url: "https://buf.build/gen/swift/git/1.27.1-20260114140118-bd09c26a260f.1/cirruslabs_tart-guest-agent_grpc_swift.git", branch: "main"),
.package(url: "https://github.com/open-telemetry/opentelemetry-swift", branch: "main"),
.package(url: "https://github.com/open-telemetry/opentelemetry-swift-core", from: "2.3.0"),
], ],
targets: [ targets: [
.executableTarget(name: "tart", dependencies: [ .executableTarget(name: "tart", dependencies: [
@@ -33,13 +37,18 @@ let package = Package(
.product(name: "SwiftDate", package: "SwiftDate"), .product(name: "SwiftDate", package: "SwiftDate"),
.product(name: "Antlr4Static", package: "Antlr4"), .product(name: "Antlr4Static", package: "Antlr4"),
.product(name: "Atomics", package: "swift-atomics"), .product(name: "Atomics", package: "swift-atomics"),
.product(name: "Sentry", package: "sentry-cocoa"),
.product(name: "TextTable", package: "TextTable"), .product(name: "TextTable", package: "TextTable"),
.product(name: "Sysctl", package: "swift-sysctl"), .product(name: "Sysctl", package: "swift-sysctl"),
.product(name: "SwiftRadix", package: "SwiftRadix"), .product(name: "SwiftRadix", package: "SwiftRadix"),
.product(name: "Semaphore", package: "Semaphore"), .product(name: "Semaphore", package: "Semaphore"),
.product(name: "DMRetry", package: "swift-retry"), .product(name: "DMRetry", package: "swift-retry"),
.product(name: "XAttr", package: "swift-xattr"), .product(name: "XAttr", package: "swift-xattr"),
.product(name: "GRPC", package: "grpc-swift"),
.product(name: "Cirruslabs_TartGuestAgent_Grpc_Swift", package: "cirruslabs_tart-guest-agent_grpc_swift"),
.product(name: "OpenTelemetryApi", package: "opentelemetry-swift-core"),
.product(name: "OpenTelemetrySdk", package: "opentelemetry-swift-core"),
.product(name: "OpenTelemetryProtocolExporterHTTP", package: "opentelemetry-swift"),
.product(name: "ResourceExtension", package: "opentelemetry-swift"),
], exclude: [ ], exclude: [
"OCI/Reference/Makefile", "OCI/Reference/Makefile",
"OCI/Reference/Reference.g4", "OCI/Reference/Reference.g4",
+2 -2
View File
@@ -66,8 +66,8 @@ Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura)
```bash ```bash
brew install cirruslabs/cli/tart brew install cirruslabs/cli/tart
tart clone ghcr.io/cirruslabs/macos-sonoma-base:latest sonoma-base tart clone ghcr.io/cirruslabs/macos-tahoe-base:latest tahoe-base
tart run sonoma-base tart run tahoe-base
``` ```
Please check the [official documentation](https://tart.run) for more information and/or feel free to use [discussions](https://github.com/cirruslabs/tart/discussions) Please check the [official documentation](https://tart.run) for more information and/or feel free to use [discussions](https://github.com/cirruslabs/tart/discussions)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

+11 -7
View File
@@ -7,19 +7,23 @@
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Tart</string> <string>Tart</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>org.cirruslabs.tart</string> <string>com.github.cirruslabs.tart</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>tart</string> <string>tart</string>
<key>LSApplicationCategoryType</key> <key>CFBundlePackageType</key>
<string>public.app-category.developer-tools</string> <string>APPL</string>
<key>CFBundleIconFiles</key> <key>LSApplicationCategoryType</key>
<array> <string>public.app-category.developer-tools</string>
<string>AppIcon.png</string> <key>CFBundleIconFile</key>
</array> <string>UPW Tart</string>
<key>CFBundleIconName</key>
<string>UPW Tart</string>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoads</key> <key>NSAllowsArbitraryLoads</key>
<true/> <true/>
</dict> </dict>
<key>NSLocalNetworkUsageDescription</key>
<string>Access to OCI registries on the local network</string>
</dict> </dict>
</plist> </plist>
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+140
View File
@@ -0,0 +1,140 @@
{
"fill" : "automatic",
"groups" : [
{
"blend-mode" : "normal",
"blur-material" : 0.5,
"layers" : [
{
"hidden" : false,
"image-name-specializations" : [
{
"value" : "4.4-–-layer.png"
},
{
"idiom" : "square",
"value" : "UPW Tart L4.png"
}
],
"name" : "UPW Tart L4"
}
],
"opacity" : 1,
"shadow" : {
"kind" : "neutral",
"opacity" : 1
},
"specular" : true,
"translucency" : {
"enabled" : true,
"value" : 0.25
}
},
{
"layers" : [
{
"image-name-specializations" : [
{
"value" : "3.3-–-layer.png"
},
{
"idiom" : "square",
"value" : "UPW Tart L3.png"
}
],
"name" : "UPW Tart L3",
"position-specializations" : [
{
"idiom" : "square",
"value" : {
"scale" : 1,
"translation-in-points" : [
0,
0
]
}
}
]
}
],
"shadow" : {
"kind" : "none",
"opacity" : 1
},
"specular" : false,
"translucency" : {
"enabled" : true,
"value" : 0.25
}
},
{
"blur-material" : null,
"layers" : [
{
"image-name-specializations" : [
{
"value" : "2.2-–-layer.png"
},
{
"idiom" : "square",
"value" : "UPW Tart L2.png"
}
],
"name" : "UPW Tart L2"
}
],
"position-specializations" : [
{
"idiom" : "square",
"value" : {
"scale" : 1,
"translation-in-points" : [
0,
0
]
}
}
],
"shadow" : {
"kind" : "none",
"opacity" : 1
},
"specular" : true,
"translucency" : {
"enabled" : true,
"value" : 0.25
}
},
{
"layers" : [
{
"image-name-specializations" : [
{
"value" : "1.1-–-layer.png"
},
{
"idiom" : "square",
"value" : "UPW Tart L1.png"
}
],
"name" : "UPW Tart L1"
}
],
"shadow" : {
"kind" : "layer-color",
"opacity" : 0.5
},
"specular" : true,
"translucency" : {
"enabled" : true,
"value" : 0.25
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIconFile</key>
<string>UPW Tart</string>
<key>CFBundleIconName</key>
<string>UPW Tart</string>
</dict>
</plist>
Binary file not shown.
+9 -4
View File
@@ -31,6 +31,9 @@ struct Clone: AsyncParsableCommand {
@Flag(help: .hidden) @Flag(help: .hidden)
var deduplicate: Bool = false var deduplicate: Bool = false
@Option(help: ArgumentHelp("limit automatic pruning to n gigabytes", valueName: "n"))
var pruneLimit: UInt = 100
func validate() throws { func validate() throws {
if newName.contains("/") { if newName.contains("/") {
throw ValidationError("<new-name> should be a local name") throw ValidationError("<new-name> should be a local name")
@@ -42,8 +45,8 @@ struct Clone: AsyncParsableCommand {
} }
func run() async throws { func run() async throws {
let ociStorage = VMStorageOCI() let ociStorage = try VMStorageOCI()
let localStorage = VMStorageLocal() let localStorage = try VMStorageLocal()
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) { if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
// Pull the VM in case it's OCI-based and doesn't exist locally yet // Pull the VM in case it's OCI-based and doesn't exist locally yet
@@ -76,8 +79,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. // 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() let unallocatedBytes = try sourceVM.sizeBytes() - sourceVM.allocatedSizeBytes()
if unallocatedBytes > 0 { // Avoid reclaiming an excessive amount of disk space.
try Prune.reclaimIfNeeded(UInt64(unallocatedBytes), sourceVM) let reclaimBytes = min(unallocatedBytes, Int(pruneLimit) * 1024 * 1024 * 1024)
if reclaimBytes > 0 {
try Prune.reclaimIfNeeded(UInt64(reclaimBytes), sourceVM)
} }
}, onCancel: { }, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL) try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
+11 -3
View File
@@ -10,7 +10,7 @@ struct Create: AsyncParsableCommand {
@Argument(help: "VM name") @Argument(help: "VM name")
var name: String var name: String
@Option(help: ArgumentHelp("create a macOS VM using path to the IPSW file or URL (or \"latest\", to fetch the latest supported IPSW automatically)", valueName: "path")) @Option(help: ArgumentHelp("create a macOS VM using path to the IPSW file or URL (or \"latest\", to fetch the latest supported IPSW automatically)", valueName: "path"), completion: .file())
var fromIPSW: String? var fromIPSW: String?
@Flag(help: "create a Linux VM") @Flag(help: "create a Linux VM")
@@ -19,6 +19,9 @@ struct Create: AsyncParsableCommand {
@Option(help: ArgumentHelp("Disk size in GB")) @Option(help: ArgumentHelp("Disk size in GB"))
var diskSize: UInt16 = 50 var diskSize: UInt16 = 50
@Option(help: ArgumentHelp("Disk image format", discussion: "ASIF format provides better performance but requires macOS 26 Tahoe or later"))
var diskFormat: DiskImageFormat = .raw
func validate() throws { func validate() throws {
if fromIPSW == nil && !linux { if fromIPSW == nil && !linux {
throw ValidationError("Please specify either a --from-ipsw or --linux option!") throw ValidationError("Please specify either a --from-ipsw or --linux option!")
@@ -28,6 +31,11 @@ struct Create: AsyncParsableCommand {
throw ValidationError("Only Linux VMs are supported on Intel!") throw ValidationError("Only Linux VMs are supported on Intel!")
} }
#endif #endif
// Validate disk format support
if !diskFormat.isSupported {
throw ValidationError("Disk format '\(diskFormat.rawValue)' is not supported on this system.")
}
} }
func run() async throws { func run() async throws {
@@ -58,12 +66,12 @@ struct Create: AsyncParsableCommand {
ipswURL = URL(fileURLWithPath: NSString(string: fromIPSW).expandingTildeInPath) ipswURL = URL(fileURLWithPath: NSString(string: fromIPSW).expandingTildeInPath)
} }
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize) _ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize, diskFormat: diskFormat)
} }
#endif #endif
if linux { if linux {
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize) _ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize, diskFormat: diskFormat)
} }
try VMStorageLocal().move(name, from: tmpVMDir) try VMStorageLocal().move(name, from: tmpVMDir)
+233
View File
@@ -0,0 +1,233 @@
import ArgumentParser
import Foundation
import NIOPosix
import GRPC
import Cirruslabs_TartGuestAgent_Grpc_Swift
struct ExecCustomExitCodeError: Error {
let exitCode: Int32
}
struct Exec: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Execute a command in a running VM", discussion: """
Requires Tart Guest Agent running in a guest VM.
Note that all non-vanilla Cirrus Labs VM images already have the Tart Guest Agent installed.
""")
@Flag(name: [.customShort("i")], help: "Attach host's standard input to a remote command")
var interactive: Bool = false
@Flag(name: [.customShort("t")], help: "Allocate a remote pseudo-terminal (PTY)")
var tty: Bool = false
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
var name: String
@Argument(parsing: .captureForPassthrough, help: "Command to execute")
var command: [String]
func run() async throws {
// We only have withThrowingDiscardingTaskGroup available starting from macOS 14
if #unavailable(macOS 14) {
throw RuntimeError.Generic("\"tart exec\" is only available on macOS 14 (Sonoma) or newer")
}
// Open VM's directory
let vmDir = try VMStorageLocal().open(name)
// Ensure that the VM is running
if try !vmDir.running() {
throw RuntimeError.VMNotRunning(name)
}
// Create a gRPC channel connected to the VM's control socket
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
defer {
try! group.syncShutdownGracefully()
}
// Change the current working directory to a VM's base directory
// to work around Unix domain socket 104 byte limitation [1]
//
// [1]: https://blog.8-p.info/en/2020/06/11/unix-domain-socket-length/
if let baseURL = vmDir.controlSocketURL.baseURL {
FileManager.default.changeCurrentDirectoryPath(baseURL.path())
}
let channel = try GRPCChannelPool.with(
target: .unixDomainSocket(vmDir.controlSocketURL.relativePath),
transportSecurity: .plaintext,
eventLoopGroup: group,
)
defer {
try! channel.close().wait()
}
// Switch controlling terminal into raw mode when remote pseudo-terminal is requested
var state: State? = nil
if tty && Term.IsTerminal() {
state = try Term.MakeRaw()
}
defer {
// Restore terminal to its initial state
if let state {
try! Term.Restore(state)
}
}
// Execute a command in a running VM
do {
try await execute(channel)
} catch let error as GRPCConnectionPoolError {
throw RuntimeError.Generic("Failed to connect to the VM using its control socket: \(error.localizedDescription), is the Tart Guest Agent running?")
}
}
private func execute(_ channel: GRPCChannel) async throws {
let agentAsyncClient = AgentAsyncClient(channel: channel)
let execCall = agentAsyncClient.makeExecCall()
try await execCall.requestStream.send(.with {
$0.type = .command(.with {
$0.name = command[0]
$0.args = Array(command.dropFirst(1))
$0.interactive = interactive
$0.tty = tty
if tty {
$0.terminalSize = .with {
let (width, height) = try! Term.GetSize()
$0.cols = UInt32(width)
$0.rows = UInt32(height)
}
}
})
})
// Process command events and optionally send our standard input and/or terminal dimensions
try await withThrowingTaskGroup { group in
// Stream host's standard input if interactive mode is enabled
if interactive {
let stdinStream = AsyncThrowingStream<Data, Error> { continuation in
let handle = FileHandle.standardInput
if isRegularFile(handle.fileDescriptor) {
// Standard input can be a regular file when input redirection (<) is used,
// in which case the handle won't receive any new readability events, so we
// just read the file normally here in chunks and consider done with it
//
// Ideally this is best handled by using non-blocking I/O, but Swift's
// standard library only offers inefficient bytes[1] property and SwiftNIO's
// NIOFileSystem doesn't seem to support opening raw file descriptors.
//
// [1]: https://developer.apple.com/documentation/foundation/filehandle/bytes
while true {
do {
let data = try handle.read(upToCount: 64 * 1024)
if let data = data {
continuation.yield(data)
} else {
continuation.finish()
break
}
} catch (let error) {
continuation.finish(throwing: error)
break
}
}
} else {
handle.readabilityHandler = { handle in
let data = handle.availableData
if data.isEmpty {
continuation.finish()
} else {
continuation.yield(data)
}
}
}
}
group.addTask {
for try await stdinData in stdinStream {
try await execCall.requestStream.send(.with {
$0.type = .standardInput(.with {
$0.data = stdinData
})
})
}
// Signal EOF as we're done reading standard input
try await execCall.requestStream.send(.with {
$0.type = .standardInput(.with {
$0.data = Data()
})
})
}
}
// Stream host's terminal dimensions if pseudo-terminal is requested
signal(SIGWINCH, SIG_IGN)
let sigwinchSrc = DispatchSource.makeSignalSource(signal: SIGWINCH)
sigwinchSrc.activate()
if tty {
let terminalDimensionsStream = AsyncStream { continuation in
sigwinchSrc.setEventHandler {
continuation.yield(try! Term.GetSize())
}
}
group.addTask {
for await (width, height) in terminalDimensionsStream {
try await execCall.requestStream.send(.with {
$0.type = .terminalResize(.with {
$0.cols = UInt32(width)
$0.rows = UInt32(height)
})
})
}
}
}
// Process command events
group.addTask {
for try await response in execCall.responseStream {
switch response.type {
case .standardOutput(let ioChunk):
try FileHandle.standardOutput.write(contentsOf: ioChunk.data)
case .standardError(let ioChunk):
try FileHandle.standardError.write(contentsOf: ioChunk.data)
case .exit(let exit):
throw ExecCustomExitCodeError(exitCode: exit.code)
default:
// Unknown event, do nothing
continue
}
}
}
while !group.isEmpty {
do {
try await group.next()
} catch {
group.cancelAll()
throw error
}
}
}
}
}
private func isRegularFile(_ fileDescriptor: Int32) -> Bool {
var stat = stat()
if fstat(fileDescriptor, &stat) != 0 {
return false
}
return (stat.st_mode & S_IFMT) == S_IFREG
}
+1 -1
View File
@@ -7,7 +7,7 @@ struct Export: AsyncParsableCommand {
@Argument(help: "Source VM name.", completion: .custom(completeMachines)) @Argument(help: "Source VM name.", completion: .custom(completeMachines))
var name: String var name: String
@Argument(help: "Path to the destination file.") @Argument(help: "Path to the destination file.", completion: .file())
var path: String? var path: String?
func run() async throws { func run() async throws {
+2 -1
View File
@@ -6,6 +6,7 @@ fileprivate struct VMInfo: Encodable {
let CPU: Int let CPU: Int
let Memory: UInt64 let Memory: UInt64
let Disk: Int let Disk: Int
let DiskFormat: String
let Size: String let Size: String
let Display: String let Display: String
let Running: Bool let Running: Bool
@@ -26,7 +27,7 @@ struct Get: AsyncParsableCommand {
let vmConfig = try VMConfig(fromURL: vmDir.configURL) let vmConfig = try VMConfig(fromURL: vmDir.configURL)
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024 let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue) let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), DiskFormat: vmConfig.diskFormat.rawValue, Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue)
print(format.renderSingle(info)) print(format.renderSingle(info))
} }
} }
+27 -12
View File
@@ -2,12 +2,11 @@ import ArgumentParser
import Foundation import Foundation
import Network import Network
import SystemConfiguration import SystemConfiguration
import Sentry
enum IPResolutionStrategy: String, ExpressibleByArgument, CaseIterable { enum IPResolutionStrategy: String, ExpressibleByArgument, CaseIterable {
case dhcp, arp case dhcp, arp, agent
private(set) static var allValueStrings: [String] = Format.allCases.map { "\($0)"} private(set) static var allValueStrings: [String] = Self.allCases.map { "\($0)"}
} }
struct IP: AsyncParsableCommand { struct IP: AsyncParsableCommand {
@@ -19,13 +18,11 @@ struct IP: AsyncParsableCommand {
@Option(help: "Number of seconds to wait for a potential VM booting") @Option(help: "Number of seconds to wait for a potential VM booting")
var wait: UInt16 = 0 var wait: UInt16 = 0
@Option(help: ArgumentHelp("Strategy for resolving IP address: dhcp or arp", @Option(help: ArgumentHelp("Strategy for resolving IP address",
discussion: """ discussion: """
By default, Tart is looking up and parsing DHCP lease file to determine the IP of the VM.\n By default, Tart is using a "dhcp" resolver which parses the DHCP lease file on host and tries to find an entry containing the VM's MAC address. This method is fast and the most reliable, but only works for VMs are not using the bridged networking.\n
This method is fast and the most reliable but only returns local IP adresses.\n Alternatively, Tart has an "arp" resolver which calls an external "arp" executable and parses it's output. This works for VMs using bridged networking and returns their IP, but when they generate enough network activity to populate the host's ARP table. Note that "arp" strategy won't work for VMs using the Softnet networking.\n
Alternatively, Tart can call external `arp` executable and parse it's output.\n A third strategy, "agent" works in all cases reliably, but requires Guest agent for Tart VMs (https://github.com/cirruslabs/tart-guest-agent) to be installed inside of a VM.
In case of enabled Bridged Networking this method will return VM's IP address on the network interface used for Bridged Networking.\n
Note that `arp` strategy won't work for VMs using `--net-softnet`.
""")) """))
var resolver: IPResolutionStrategy = .dhcp var resolver: IPResolutionStrategy = .dhcp
@@ -34,14 +31,16 @@ struct IP: AsyncParsableCommand {
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL) let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)! let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
guard let ip = try await IP.resolveIP(vmMACAddress, resolutionStrategy: resolver, secondsToWait: wait) else { guard let ip = try await IP.resolveIP(vmMACAddress, resolutionStrategy: resolver, secondsToWait: wait, controlSocketURL: vmDir.controlSocketURL) else {
var message = "no IP address found" var message = "no IP address found"
if try !vmDir.running() { if try !vmDir.running() {
message += ", is your VM running?" message += ", is your VM running?"
} }
if (vmConfig.os == .linux && resolver == .arp) { if (resolver == .agent) {
message += " (also make sure that Guest agent for Tart is running inside of a VM)"
} else if (vmConfig.os == .linux && resolver == .arp) {
message += " (not all Linux distributions are compatible with the ARP resolver)" message += " (not all Linux distributions are compatible with the ARP resolver)"
} }
@@ -51,7 +50,7 @@ struct IP: AsyncParsableCommand {
print(ip) print(ip)
} }
static public func resolveIP(_ vmMACAddress: MACAddress, resolutionStrategy: IPResolutionStrategy = .dhcp, secondsToWait: UInt16 = 0) async throws -> IPv4Address? { static public func resolveIP(_ vmMACAddress: MACAddress, resolutionStrategy: IPResolutionStrategy = .dhcp, secondsToWait: UInt16 = 0, controlSocketURL: URL? = nil) async throws -> IPv4Address? {
let waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)! let waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)!
repeat { repeat {
@@ -64,6 +63,22 @@ struct IP: AsyncParsableCommand {
if let leases = try Leases(), let ip = leases.ResolveMACAddress(macAddress: vmMACAddress) { if let leases = try Leases(), let ip = leases.ResolveMACAddress(macAddress: vmMACAddress) {
return ip return ip
} }
case .agent:
guard let controlSocketURL = controlSocketURL else {
throw RuntimeError.Generic("Cannot perform IP resolution via Tart Guest Agent when control socket URL is not set")
}
// Change the current working directory to a VM's base directory
// to work around Unix domain socket 104 byte limitation [1]
//
// [1]: https://blog.8-p.info/en/2020/06/11/unix-domain-socket-length/
if let baseURL = controlSocketURL.baseURL {
FileManager.default.changeCurrentDirectoryPath(baseURL.path())
}
if let ip = try await AgentResolver.ResolveIP(controlSocketURL.relativePath) {
return ip
}
} }
// wait a second // wait a second
+3 -3
View File
@@ -4,10 +4,10 @@ import Foundation
struct Import: AsyncParsableCommand { struct Import: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Import VM from a compressed .tvm file") static var configuration = CommandConfiguration(abstract: "Import VM from a compressed .tvm file")
@Argument(help: "Path to a file created with \"tart export\".") @Argument(help: "Path to a file created with \"tart export\".", completion: .file())
var path: String var path: String
@Argument(help: "Destination VM name.") @Argument(help: "Destination VM name.", completion: .custom(completeLocalMachines))
var name: String var name: String
func validate() throws { func validate() throws {
@@ -17,7 +17,7 @@ struct Import: AsyncParsableCommand {
} }
func run() async throws { func run() async throws {
let localStorage = VMStorageLocal() let localStorage = try VMStorageLocal()
// Create a temporary VM directory to which we will load the export file // Create a temporary VM directory to which we will load the export file
let tmpVMDir = try VMDirectory.temporary() let tmpVMDir = try VMDirectory.temporary()
+32 -4
View File
@@ -7,7 +7,7 @@ fileprivate struct VMInfo: Encodable {
let Name: String let Name: String
let Disk: Int let Disk: Int
let Size: Int let Size: Int
let SizeOnDisk: Int let Accessed: String
let Running: Bool let Running: Bool
let State: String let State: String
} }
@@ -18,7 +18,7 @@ struct List: AsyncParsableCommand {
@Option(help: ArgumentHelp("Only display VMs from the specified source (e.g. --source local, --source oci).")) @Option(help: ArgumentHelp("Only display VMs from the specified source (e.g. --source local, --source oci)."))
var source: String? var source: String?
@Option(help: "Output format: text or json") @Option(help: "Output format: text or json", completion: .list(["text", "json"]))
var format: Format = .text var format: Format = .text
@Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names.")) @Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names."))
@@ -39,13 +39,29 @@ struct List: AsyncParsableCommand {
if source == nil || source == "local" { if source == nil || source == "local" {
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
try VMInfo(Source: "local", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), SizeOnDisk: vmDir.allocatedSizeGB() - vmDir.deduplicatedSizeGB(), Running: vmDir.running(), State: vmDir.state().rawValue) try VMInfo(
Source: "local",
Name: name,
Disk: vmDir.sizeGB(),
Size: vmDir.allocatedSizeGB(),
Accessed: formatAccessDate(try vmDir.accessDate()),
Running: vmDir.running(),
State: vmDir.state().rawValue
)
}) })
} }
if source == nil || source == "oci" { if source == nil || source == "oci" {
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
try VMInfo(Source: "OCI", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), SizeOnDisk: vmDir.allocatedSizeGB() - vmDir.deduplicatedSizeGB(), Running: vmDir.running(), State: vmDir.state().rawValue) try VMInfo(
Source: "OCI",
Name: name,
Disk: vmDir.sizeGB(),
Size: vmDir.allocatedSizeGB(),
Accessed: formatAccessDate(try vmDir.accessDate()),
Running: vmDir.running(),
State: vmDir.state().rawValue
)
}) })
} }
@@ -61,4 +77,16 @@ struct List: AsyncParsableCommand {
private func sortedInfos(_ infos: [VMInfo]) -> [VMInfo] { private func sortedInfos(_ infos: [VMInfo]) -> [VMInfo] {
infos.sorted(by: { left, right in left.Name < right.Name }) infos.sorted(by: { left, right in left.Name < right.Name })
} }
private func formatAccessDate(_ accessDate: Date) -> String {
switch format {
case .text:
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .full
return formatter.localizedString(for: accessDate, relativeTo: Date())
case .json:
let formatter = ISO8601DateFormatter()
return formatter.string(from: accessDate)
}
}
} }
+2
View File
@@ -64,6 +64,8 @@ struct Login: AsyncParsableCommand {
} }
fileprivate class DictionaryCredentialsProvider: CredentialsProvider { fileprivate class DictionaryCredentialsProvider: CredentialsProvider {
let userFriendlyName = "static dictionary credentials provider"
var credentials: Dictionary<String, (String, String)> var credentials: Dictionary<String, (String, String)>
init(_ credentials: Dictionary<String, (String, String)>) { init(_ credentials: Dictionary<String, (String, String)>) {
+24 -23
View File
@@ -1,13 +1,13 @@
import ArgumentParser import ArgumentParser
import Dispatch import Dispatch
import Sentry import OpenTelemetryApi
import SwiftUI import SwiftUI
import SwiftDate import SwiftDate
struct Prune: AsyncParsableCommand { struct Prune: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches or local VMs") static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches or local VMs")
@Option(help: ArgumentHelp("Entries to remove: \"caches\" targets OCI and IPSW caches and \"vms\" targets local VMs.")) @Option(help: ArgumentHelp("Entries to remove: \"caches\" targets OCI and IPSW caches and \"vms\" targets local VMs."), completion: .list(["caches", "vms"]))
var entries: String = "caches" var entries: String = "caches"
@Option(help: ArgumentHelp("Remove entries that were last accessed more than n days ago", @Option(help: ArgumentHelp("Remove entries that were last accessed more than n days ago",
@@ -53,9 +53,9 @@ struct Prune: AsyncParsableCommand {
switch entries { switch entries {
case "caches": case "caches":
prunableStorages = [VMStorageOCI(), try IPSWCache()] prunableStorages = [try VMStorageOCI(), try IPSWCache()]
case "vms": case "vms":
prunableStorages = [VMStorageLocal()] prunableStorages = [try VMStorageLocal()]
default: default:
throw ValidationError("unsupported --entries value, please specify either \"caches\" or \"vms\"") throw ValidationError("unsupported --entries value, please specify either \"caches\" or \"vms\"")
} }
@@ -109,9 +109,10 @@ struct Prune: AsyncParsableCommand {
return return
} }
SentrySDK.configureScope { scope in OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
scope.setContext(value: ["requiredBytes": requiredBytes], key: "Prune") key: "prune.required-bytes",
} value: .int(Int(requiredBytes))
)
// Figure out how much disk space is available // Figure out how much disk space is available
let attrs = try Config().tartCacheDir.resourceValues(forKeys: [ let attrs = try Config().tartCacheDir.resourceValues(forKeys: [
@@ -123,18 +124,14 @@ struct Prune: AsyncParsableCommand {
UInt64(attrs.volumeAvailableCapacityForImportantUsage!) UInt64(attrs.volumeAvailableCapacityForImportantUsage!)
) )
SentrySDK.configureScope { scope in OpenTelemetry.instance.contextProvider.activeSpan?.setAttributes([
scope.setContext(value: [ "prune.volume-available-capacity-bytes": .int(Int(attrs.volumeAvailableCapacity!)),
"volumeAvailableCapacity": attrs.volumeAvailableCapacity!, "prune.volume-available-capacity-for-important-usage-bytes": .int(Int(attrs.volumeAvailableCapacityForImportantUsage!)),
"volumeAvailableCapacityForImportantUsage": attrs.volumeAvailableCapacityForImportantUsage!, "prune.volume-available-capacity-calculated": .int(Int(volumeAvailableCapacityCalculated)),
"volumeAvailableCapacityCalculated": volumeAvailableCapacityCalculated ])
], key: "Prune")
}
if volumeAvailableCapacityCalculated <= 0 { if volumeAvailableCapacityCalculated <= 0 {
SentrySDK.capture(message: "Zero volume capacity reported") { scope in OpenTelemetry.instance.contextProvider.activeSpan?.addEvent(name: "Zero volume capacity reported")
scope.setLevel(.warning)
}
return return
} }
@@ -149,10 +146,10 @@ struct Prune: AsyncParsableCommand {
} }
private static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws { private static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws {
let transaction = SentrySDK.startTransaction(name: "Pruning cache", operation: "prune", bindToScope: true) let span = OTel.shared.tracer.spanBuilder(spanName: "prune").startSpan()
defer { transaction.finish() } defer { span.end() }
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()] let prunableStorages: [PrunableStorage] = [try VMStorageOCI(), try IPSWCache()]
let prunables: [Prunable] = try prunableStorages let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() } .flatMap { try $0.prunables() }
.sorted { try $0.accessDate() < $1.accessDate() } .sorted { try $0.accessDate() < $1.accessDate() }
@@ -177,13 +174,17 @@ struct Prune: AsyncParsableCommand {
continue continue
} }
try SentrySDK.span?.setData(value: prunable.allocatedSizeBytes(), key: prunable.url.path) let allocatedSizeBytes = try prunable.allocatedSizeBytes()
cacheReclaimedBytes += try prunable.allocatedSizeBytes() OpenTelemetry.instance.contextProvider.activeSpan?
.addEvent(name: "Pruned \(allocatedSizeBytes) bytes for \(prunable.url.path)")
cacheReclaimedBytes += allocatedSizeBytes
try prunable.delete() try prunable.delete()
} }
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte); OpenTelemetry.instance.contextProvider.activeSpan?
.addEvent(name: "Reclaimed \(cacheReclaimedBytes) bytes")
} }
} }
+1 -1
View File
@@ -35,7 +35,7 @@ struct Pull: AsyncParsableCommand {
func run() async throws { func run() async throws {
// Be more liberal when accepting local image as argument, // Be more liberal when accepting local image as argument,
// see https://github.com/cirruslabs/tart/issues/36 // 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!") print("\"\(remoteName)\" is a local image, nothing to pull here!")
return return
+29 -5
View File
@@ -26,15 +26,17 @@ struct Push: AsyncParsableCommand {
""")) """))
var chunkSize: Int = 0 var chunkSize: Int = 0
@Option(help: .hidden)
var diskFormat: String = "v2" @Option(name: [.customLong("label")], help: ArgumentHelp("additional metadata to attach to the OCI image configuration in key=value format",
discussion: "Can be specified multiple times to attach multiple labels."))
var labels: [String] = []
@Flag(help: ArgumentHelp("cache pushed images locally", @Flag(help: ArgumentHelp("cache pushed images locally",
discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later.")) discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
var populateCache: Bool = false var populateCache: Bool = false
func run() async throws { func run() async throws {
let ociStorage = VMStorageOCI() let ociStorage = try VMStorageOCI()
let localVMDir = try VMStorageHelper.open(localName) let localVMDir = try VMStorageHelper.open(localName)
let lock = try localVMDir.lock() let lock = try localVMDir.lock()
if try !lock.trylock() { if try !lock.trylock() {
@@ -80,8 +82,8 @@ struct Push: AsyncParsableCommand {
registry: registry, registry: registry,
references: references, references: references,
chunkSizeMb: chunkSize, chunkSizeMb: chunkSize,
diskFormat: diskFormat, concurrency: concurrency,
concurrency: concurrency labels: parseLabels()
) )
// Populate the local cache (if requested) // Populate the local cache (if requested)
if populateCache { if populateCache {
@@ -115,6 +117,28 @@ struct Push: AsyncParsableCommand {
return RemoteName(host: registry.host!, namespace: registry.namespace, return RemoteName(host: registry.host!, namespace: registry.namespace,
reference: Reference(digest: digest)) reference: Reference(digest: digest))
} }
// Helper method to convert labels array to dictionary
func parseLabels() -> [String: String] {
var result = [String: String]()
for label in labels {
let parts = label.trimmingCharacters(in: .whitespaces).split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
let key = parts.count > 0 ? String(parts[0]) : ""
let value = parts.count > 1 ? String(parts[1]) : ""
// It sometimes makes sense to provide an empty value,
// but not an empty key
if key.isEmpty {
continue
}
result[key] = value
}
return result
}
} }
extension Collection where Element == RemoteName { extension Collection where Element == RemoteName {
+1 -1
View File
@@ -17,7 +17,7 @@ struct Rename: AsyncParsableCommand {
} }
func run() async throws { func run() async throws {
let localStorage = VMStorageLocal() let localStorage = try VMStorageLocal()
if !localStorage.exists(name) { if !localStorage.exists(name) {
throw ValidationError("failed to rename a non-existent local VM: \(name)") throw ValidationError("failed to rename a non-existent local VM: \(name)")
+90 -22
View File
@@ -4,7 +4,7 @@ import Darwin
import Dispatch import Dispatch
import SwiftUI import SwiftUI
import Virtualization import Virtualization
import Sentry import OpenTelemetryApi
import System import System
var vm: VM? var vm: VM?
@@ -81,7 +81,7 @@ struct Run: AsyncParsableCommand {
@Option(help: ArgumentHelp( @Option(help: ArgumentHelp(
"Attach an externally created serial console", "Attach an externally created serial console",
discussion: "Alternative to `--serial` flag for programmatic integrations." discussion: "Alternative to `--serial` flag for programmatic integrations."
)) ), completion: .file())
var serialPath: String? var serialPath: String?
@Flag(help: ArgumentHelp("Force open a UI window, even when VNC is enabled.", visibility: .private)) @Flag(help: ArgumentHelp("Force open a UI window, even when VNC is enabled.", visibility: .private))
@@ -92,7 +92,7 @@ struct Run: AsyncParsableCommand {
@Flag(help: ArgumentHelp( @Flag(help: ArgumentHelp(
"Disable clipboard sharing between host and guest.", "Disable clipboard sharing between host and guest.",
discussion: "Only works with Linux-based guest operating systems.")) discussion: "Clipboard sharing requires spice-vdagent package on Linux and https://github.com/cirruslabs/tart-guest-agent on macOS."))
var noClipboard: Bool = false var noClipboard: Bool = false
#if arch(arm64) #if arch(arm64)
@@ -117,7 +117,7 @@ struct Run: AsyncParsableCommand {
var vncExperimental: Bool = false var vncExperimental: Bool = false
@Option(help: ArgumentHelp(""" @Option(help: ArgumentHelp("""
Additional disk attachments with an optional read-only and synchronization options (e.g. --disk="disk.bin", --disk="ubuntu.iso:ro", --disk="/dev/disk0", --disk "ghcr.io/cirruslabs/xcode:16.0:ro" or --disk="nbd://localhost:10809/myDisk:sync=none") Additional disk attachments with an optional read-only and synchronization options in the form of path[:options] (e.g. --disk="disk.bin", --disk="ubuntu.iso:ro", --disk="/dev/disk0", --disk "ghcr.io/cirruslabs/xcode:16.0:ro" or --disk="nbd://localhost:10809/myDisk:sync=none")
""", discussion: """ """, discussion: """
The disk attachment can be a: The disk attachment can be a:
@@ -134,12 +134,13 @@ 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 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 macos --disk=/dev/diskX
sudo TART_HOME="$HOME/.tart" tart run sonoma --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]")) """, valueName: "path[:options]"), completion: .file())
var disk: [String] = [] var disk: [String] = []
#if arch(arm64) #if arch(arm64)
@@ -158,7 +159,7 @@ struct Run: AsyncParsableCommand {
#endif #endif
var rosettaTag: String? var rosettaTag: String?
@Option(help: ArgumentHelp("Additional directory shares with an optional read-only and mount tag options (e.g. --dir=\"~/src/build\" or --dir=\"~/src/sources:ro\")", discussion: """ @Option(help: ArgumentHelp("Additional directory shares with an optional read-only and mount tag options in the form of [name:]path[:options] (e.g. --dir=\"~/src/build\" or --dir=\"~/src/sources:ro\")", discussion: """
Requires host to be macOS 13.0 (Ventura) or newer. macOS guests must be running macOS 13.0 (Ventura) or newer too. Requires host to be macOS 13.0 (Ventura) or newer. macOS guests must be running macOS 13.0 (Ventura) or newer too.
Options are comma-separated and are as follows: Options are comma-separated and are as follows:
@@ -170,7 +171,7 @@ struct Run: AsyncParsableCommand {
Mount tag can be overridden by appending tag property to the directory share (e.g. --dir=\"~/src/build:tag=build\" or --dir=\"~/src/build:ro,tag=build\"). Then it can be mounted via "mount_virtiofs build ~/build" inside guest macOS and "mount -t virtiofs build ~/build" inside guest Linux. Mount tag can be overridden by appending tag property to the directory share (e.g. --dir=\"~/src/build:tag=build\" or --dir=\"~/src/build:ro,tag=build\"). Then it can be mounted via "mount_virtiofs build ~/build" inside guest macOS and "mount -t virtiofs build ~/build" inside guest Linux.
In case of passing multiple directories per mount tag it is required to prefix them with names e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\". These names will be used as directory names under the mounting point inside guests. For the example above it will be "/Volumes/My Shared Files/build" and "/Volumes/My Shared Files/sources" respectively. In case of passing multiple directories per mount tag it is required to prefix them with names e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\". These names will be used as directory names under the mounting point inside guests. For the example above it will be "/Volumes/My Shared Files/build" and "/Volumes/My Shared Files/sources" respectively.
""", valueName: "[name:]path[:options]")) """, valueName: "[name:]path[:options]"), completion: .directory)
var dir: [String] = [] var dir: [String] = []
@Flag(help: ArgumentHelp("Enable nested virtualization if possible")) @Flag(help: ArgumentHelp("Enable nested virtualization if possible"))
@@ -201,15 +202,28 @@ struct Run: AsyncParsableCommand {
""")) """))
var netSoftnet: Bool = false 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. 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. Implies --net-softnet.
""", valueName: "comma-separated CIDRs")) """, valueName: "comma-separated CIDRs"))
var netSoftnetAllow: String? 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: """ @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: Options are comma-separated and are as follows:
@@ -260,13 +274,24 @@ struct Run: AsyncParsableCommand {
#endif #endif
var captureSystemKeys: Bool = false var captureSystemKeys: Bool = false
#if arch(arm64)
@Flag(help: ArgumentHelp("Don't add trackpad as a pointing device on macOS VMs"))
#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 { mutating func validate() throws {
if vnc && vncExperimental { if vnc && vncExperimental {
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive") throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
} }
// Automatically enable --net-softnet when any of its related options are specified // 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 netSoftnet = true
} }
@@ -290,13 +315,13 @@ struct Run: AsyncParsableCommand {
if nested { if nested {
if #unavailable(macOS 15) { if #unavailable(macOS 15) {
throw ValidationError("Nested virtualization is supported on hosts starting with macOS 15 (Sequia), and later.") throw ValidationError("Nested virtualization is supported on hosts starting with macOS 15 (Sequoia), and later.")
} else if !VZGenericPlatformConfiguration.isNestedVirtualizationSupported { } else if !VZGenericPlatformConfiguration.isNestedVirtualizationSupported {
throw ValidationError("Nested virtualization is available for Mac with the M3 chip, and later.") throw ValidationError("Nested virtualization is available for Mac with the M3 chip, and later.")
} }
} }
let localStorage = VMStorageLocal() let localStorage = try VMStorageLocal()
let vmDir = try localStorage.open(name) let vmDir = try localStorage.open(name)
if try vmDir.state() == .Suspended { if try vmDir.state() == .Suspended {
suspendable = true suspendable = true
@@ -307,8 +332,23 @@ struct Run: AsyncParsableCommand {
if !(config.platform is PlatformSuspendable) { if !(config.platform is PlatformSuspendable) {
throw ValidationError("You can only suspend macOS VMs") throw ValidationError("You can only suspend macOS VMs")
} }
if dir.count > 0 {
throw ValidationError("Suspending VMs with shared directories is not supported") 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 {
throw ValidationError("--no-trackpad can only be used with macOS VMs")
} }
} }
@@ -321,9 +361,15 @@ struct Run: AsyncParsableCommand {
@MainActor @MainActor
func run() async throws { func run() async throws {
let localStorage = VMStorageLocal() let localStorage = try VMStorageLocal()
let vmDir = try localStorage.open(name) let vmDir = try localStorage.open(name)
// Validate disk format support
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
if !vmConfig.diskFormat.isSupported {
throw ValidationError("Disk format '\(vmConfig.diskFormat.rawValue)' is not supported on this system.")
}
let storageLock = try FileLock(lockURL: Config().tartHomeDir) let storageLock = try FileLock(lockURL: Config().tartHomeDir)
try storageLock.lock() try storageLock.lock()
// check if there is a running VM with the same MAC address // check if there is a running VM with the same MAC address
@@ -373,7 +419,10 @@ struct Run: AsyncParsableCommand {
audio: !noAudio, audio: !noAudio,
clipboard: !noClipboard, clipboard: !noClipboard,
sync: VZDiskImageSynchronizationMode(diskOptions.syncModeRaw), sync: VZDiskImageSynchronizationMode(diskOptions.syncModeRaw),
caching: VZDiskImageCachingMode(diskOptions.cachingModeRaw) caching: VZDiskImageCachingMode(diskOptions.cachingModeRaw),
noTrackpad: noTrackpad,
noPointer: noPointer,
noKeyboard: noKeyboard
) )
let vncImpl: VNC? = try { let vncImpl: VNC? = try {
@@ -464,20 +513,27 @@ struct Run: AsyncParsableCommand {
} }
} }
if #available(macOS 14, *) {
Task {
try await ControlSocket(vmDir.controlSocketURL).run()
}
}
try await vm!.run() try await vm!.run()
if let vncImpl = vncImpl { if let vncImpl = vncImpl {
try vncImpl.stop() try vncImpl.stop()
} }
OTel.shared.flush()
Foundation.exit(0) Foundation.exit(0)
} catch { } catch {
// Capture the error into Sentry // Capture the error into OpenTelemetry
SentrySDK.capture(error: error) OpenTelemetry.instance.contextProvider.activeSpan?.recordException(error)
SentrySDK.flush(timeout: 2.seconds.timeInterval)
fputs("\(error)\n", stderr) fputs("\(error)\n", stderr)
OTel.shared.flush()
Foundation.exit(1) Foundation.exit(1)
} }
} }
@@ -511,12 +567,14 @@ struct Run: AsyncParsableCommand {
} else { } else {
print(RuntimeError.SuspendFailed("this functionality is only supported on macOS 14 (Sonoma) or newer")) print(RuntimeError.SuspendFailed("this functionality is only supported on macOS 14 (Sonoma) or newer"))
OTel.shared.flush()
Foundation.exit(1) Foundation.exit(1)
} }
#endif #endif
} catch (let e) { } catch (let e) {
print(RuntimeError.SuspendFailed(e.localizedDescription)) print(RuntimeError.SuspendFailed(e.localizedDescription))
OTel.shared.flush()
Foundation.exit(1) Foundation.exit(1)
} }
} }
@@ -568,6 +626,10 @@ struct Run: AsyncParsableCommand {
softnetExtraArguments += ["--allow", netSoftnetAllow] softnetExtraArguments += ["--allow", netSoftnetAllow]
} }
if let netSoftnetBlock = netSoftnetBlock {
softnetExtraArguments += ["--block", netSoftnetBlock]
}
if let netSoftnetExpose = netSoftnetExpose { if let netSoftnetExpose = netSoftnetExpose {
softnetExtraArguments += ["--expose", netSoftnetExpose] softnetExtraArguments += ["--expose", netSoftnetExpose]
} }
@@ -757,6 +819,12 @@ struct MainApp: App {
} }
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
func applicationDidFinishLaunching(_ : Notification) {
let nsApp = NSApplication.shared
nsApp.setActivationPolicy(.regular)
nsApp.activate(ignoringOtherApps: true)
}
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
if (kill(getpid(), MainApp.suspendable ? SIGUSR1 : SIGINT) == 0) { if (kill(getpid(), MainApp.suspendable ? SIGUSR1 : SIGINT) == 0) {
return .terminateLater return .terminateLater
+16 -11
View File
@@ -14,7 +14,7 @@ struct Set: AsyncParsableCommand {
@Option(help: "VM memory size in megabytes") @Option(help: "VM memory size in megabytes")
var memory: UInt64? var memory: UInt64?
@Option(help: "VM display resolution in a format of <width>x<height>. 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? var display: VMDisplayConfig?
@Flag(inversion: .prefixedNo, help: ArgumentHelp("Whether to automatically reconfigure the VM's display to fit the window")) @Flag(inversion: .prefixedNo, help: ArgumentHelp("Whether to automatically reconfigure the VM's display to fit the window"))
@@ -33,15 +33,7 @@ struct Set: AsyncParsableCommand {
@Option(help: ArgumentHelp("Resize the VMs disk to the specified size in GB (note that the disk size can only be increased to avoid losing data)", @Option(help: ArgumentHelp("Resize the VMs disk to the specified size in GB (note that the disk size can only be increased to avoid losing data)",
discussion: """ discussion: """
Disk resizing works on most cloud-ready Linux distributions out-of-the box (e.g. Ubuntu Cloud Images See https://tart.run/faq/#disk-resizing for more details.
have the \"cloud-initramfs-growroot\" package installed that runs on boot) and on the rest of the
distributions by running the \"growpart\" or \"resize2fs\" commands.
For macOS, however, things are a bit more complicated: you need to remove the recovery partition
first and then run various \"diskutil\" commands, see Tart's packer plugin source code for more
details[1].
[1]: https://github.com/cirruslabs/packer-plugin-tart/blob/main/builder/tart/step_disk_resize.go
""")) """))
var diskSize: UInt16? var diskSize: UInt16?
@@ -64,6 +56,7 @@ struct Set: AsyncParsableCommand {
if (display.height > 0) { if (display.height > 0) {
vmConfig.display.height = display.height vmConfig.display.height = display.height
} }
vmConfig.display.unit = display.unit
} }
vmConfig.displayRefit = displayRefit vmConfig.displayRefit = displayRefit
@@ -96,12 +89,24 @@ struct Set: AsyncParsableCommand {
extension VMDisplayConfig: ExpressibleByArgument { extension VMDisplayConfig: ExpressibleByArgument {
public init(argument: String) { 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 { let parts = argument.components(separatedBy: "x").map {
Int($0) ?? 0 Int($0) ?? 0
} }
self = VMDisplayConfig( self = VMDisplayConfig(
width: parts[safe: 0] ?? 0, width: parts[safe: 0] ?? 0,
height: parts[safe: 1] ?? 0 height: parts[safe: 1] ?? 0,
unit: unit,
) )
} }
} }
+22 -1
View File
@@ -9,7 +9,8 @@ struct Config {
var tartHomeDir: URL var tartHomeDir: URL
if let customTartHome = ProcessInfo.processInfo.environment["TART_HOME"] { if let customTartHome = ProcessInfo.processInfo.environment["TART_HOME"] {
tartHomeDir = URL(fileURLWithPath: customTartHome) tartHomeDir = URL(fileURLWithPath: customTartHome, isDirectory: true)
try Self.validateTartHome(url: tartHomeDir)
} else { } else {
tartHomeDir = FileManager.default tartHomeDir = FileManager.default
.homeDirectoryForCurrentUser .homeDirectoryForCurrentUser
@@ -49,4 +50,24 @@ struct Config {
static func jsonDecoder() -> JSONDecoder { static func jsonDecoder() -> 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)")
}
}
}
} }
+97
View File
@@ -0,0 +1,97 @@
import Foundation
import Network
import os.log
import NIO
import NIOPosix
@available(macOS 14, *)
class ControlSocket {
let controlSocketURL: URL
let vmPort: UInt32
let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
let logger: os.Logger = os.Logger(subsystem: "org.cirruslabs.tart.control-socket", category: "network")
init(_ controlSocketURL: URL, vmPort: UInt32 = 8080) {
self.controlSocketURL = controlSocketURL
self.vmPort = vmPort
}
func run() async throws {
// Remove control socket file from previous "tart run" invocations,
// if any, otherwise we may get the "address already in use" error
try? FileManager.default.removeItem(atPath: controlSocketURL.path)
// Change the current working directory to a VM's base directory
// to work around Unix domain socket 104 byte limitation [1]
//
// [1]: https://blog.8-p.info/en/2020/06/11/unix-domain-socket-length/
if let baseURL = controlSocketURL.baseURL {
FileManager.default.changeCurrentDirectoryPath(baseURL.path())
}
let serverChannel = try await ServerBootstrap(group: eventLoopGroup)
.bind(unixDomainSocketPath: controlSocketURL.relativePath) { childChannel in
childChannel.eventLoop.makeCompletedFuture {
return try NIOAsyncChannel<ByteBuffer, ByteBuffer>(
wrappingChannelSynchronously: childChannel
)
}
}
try await withThrowingDiscardingTaskGroup { group in
try await serverChannel.executeThenClose { serverInbound in
for try await clientChannel in serverInbound {
group.addTask {
try await self.handleClient(clientChannel)
}
}
}
}
}
func handleClient(_ clientChannel: NIOAsyncChannel<ByteBuffer, ByteBuffer>) async throws {
self.logger.info("received new control socket connection from a client")
try await clientChannel.executeThenClose { clientInbound, clientOutbound in
self.logger.info("dialing to VM on port \(self.vmPort)...")
do {
guard let vmConnection = try await vm?.connect(toPort: self.vmPort) else {
throw RuntimeError.VMSocketFailed(self.vmPort, "VM is not running")
}
self.logger.info("running control socket proxy")
let vmChannel = try await ClientBootstrap(group: eventLoopGroup).withConnectedSocket(vmConnection.fileDescriptor) { childChannel in
childChannel.eventLoop.makeCompletedFuture {
try NIOAsyncChannel<ByteBuffer, ByteBuffer>(
wrappingChannelSynchronously: childChannel
)
}
}
try await vmChannel.executeThenClose { (vmInbound, vmOutbound) in
try await withThrowingDiscardingTaskGroup { group in
// Proxy data from a client (e.g. "tart exec") to a VM
group.addTask {
for try await message in clientInbound {
try await vmOutbound.write(message)
}
}
// Proxy data from a VM to a client (e.g. "tart exec")
group.addTask {
for try await message in vmInbound {
try await clientOutbound.write(message)
}
}
}
}
self.logger.info("control socket client disconnected")
} catch (let error) {
self.logger.error("control socket connection failed: \(error)")
}
}
}
}
@@ -5,6 +5,7 @@ enum CredentialsProviderError: Error {
} }
protocol CredentialsProvider { protocol CredentialsProvider {
var userFriendlyName: String { get }
func retrieve(host: String) throws -> (String, String)? func retrieve(host: String) throws -> (String, String)?
func store(host: String, user: String, password: String) throws func store(host: String, user: String, password: String) throws
} }
@@ -1,6 +1,8 @@
import Foundation import Foundation
class DockerConfigCredentialsProvider: CredentialsProvider { class DockerConfigCredentialsProvider: CredentialsProvider {
let userFriendlyName = "Docker configuration credentials provider"
func retrieve(host: String) throws -> (String, String)? { func retrieve(host: String) throws -> (String, String)? {
let dockerConfigURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json") let dockerConfigURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json")
if !FileManager.default.fileExists(atPath: dockerConfigURL.path) { if !FileManager.default.fileExists(atPath: dockerConfigURL.path) {
@@ -1,6 +1,8 @@
import Foundation import Foundation
class EnvironmentCredentialsProvider: CredentialsProvider { class EnvironmentCredentialsProvider: CredentialsProvider {
let userFriendlyName = "environment variable credentials provider"
func retrieve(host: String) throws -> (String, String)? { func retrieve(host: String) throws -> (String, String)? {
if let tartRegistryHostname = ProcessInfo.processInfo.environment["TART_REGISTRY_HOSTNAME"], if let tartRegistryHostname = ProcessInfo.processInfo.environment["TART_REGISTRY_HOSTNAME"],
tartRegistryHostname != host { tartRegistryHostname != host {
@@ -1,6 +1,8 @@
import Foundation import Foundation
class KeychainCredentialsProvider: CredentialsProvider { class KeychainCredentialsProvider: CredentialsProvider {
let userFriendlyName = "Keychain credentials provider"
func retrieve(host: String) throws -> (String, String)? { func retrieve(host: String) throws -> (String, String)? {
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword, let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS, kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
@@ -6,6 +6,8 @@ enum StdinCredentialsError: Error {
} }
class StdinCredentials { class StdinCredentials {
let userFriendlyName = "standard input credentials provider"
static func retrieve() throws -> (String, String) { static func retrieve() throws -> (String, String) {
let user = try readStdinCredential(name: "username", prompt: "User: ", isSensitive: false) let user = try readStdinCredential(name: "username", prompt: "User: ", isSensitive: false)
let password = try readStdinCredential(name: "password", prompt: "Password: ", isSensitive: true) let password = try readStdinCredential(name: "password", prompt: "Password: ", isSensitive: true)
@@ -13,7 +15,7 @@ class StdinCredentials {
return (user, password) return (user, password)
} }
private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 1024, isSensitive: Bool) throws -> String { private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 8192, isSensitive: Bool) throws -> String {
var buf = [CChar](repeating: 0, count: maxCharacters + 1 /* sentinel */ + 1 /* NUL */) var buf = [CChar](repeating: 0, count: maxCharacters + 1 /* sentinel */ + 1 /* NUL */)
guard let rawCredential = readpassphrase(prompt, &buf, buf.count, isSensitive ? RPP_ECHO_OFF : RPP_ECHO_ON) else { guard let rawCredential = readpassphrase(prompt, &buf, buf.count, isSensitive ? RPP_ECHO_OFF : RPP_ECHO_ON) else {
throw StdinCredentialsError.CredentialRequired(which: name) throw StdinCredentialsError.CredentialRequired(which: name)
+43
View File
@@ -0,0 +1,43 @@
import Foundation
import ArgumentParser
enum DiskImageFormat: String, CaseIterable, Codable {
case raw = "raw"
case asif = "asif"
var displayName: String {
switch self {
case .raw:
return "RAW"
case .asif:
return "ASIF (Apple Sparse Image Format)"
}
}
/// Check if the format is supported on the current system
var isSupported: Bool {
switch self {
case .raw:
return true
case .asif:
if #available(macOS 26, *) {
return true
} else {
return false
}
}
}
}
extension DiskImageFormat: ExpressibleByArgument {
init?(argument: String) {
self.init(rawValue: argument.lowercased())
}
static var allValueStrings: [String] {
return allCases.map { $0.rawValue }
}
}
+108
View File
@@ -0,0 +1,108 @@
import Foundation
struct ImageInfo: Codable {
let sizeInfo: SizeInfo?
let size: UInt64?
enum CodingKeys: String, CodingKey {
case sizeInfo = "Size Info"
case size = "Size"
}
func totalBytes() throws -> Int {
if let totalBytes = self.sizeInfo?.totalBytes {
return Int(totalBytes)
}
if let size = self.size {
return Int(size)
}
throw RuntimeError.Generic("Could not find size information in disk image info")
}
}
struct SizeInfo: Codable {
let totalBytes: UInt64?
enum CodingKeys: String, CodingKey {
case totalBytes = "Total Bytes"
}
}
struct Diskutil {
static func imageCreate(diskURL: URL, sizeGB: UInt16) throws {
do {
_ = try run([
"image", "create", "blank",
"--format", "ASIF",
"--size", "\(sizeGB)G",
"--volumeName", "Tart",
diskURL.path
])
} catch {
throw RuntimeError.FailedToCreateDisk("Failed to create ASIF disk image: \(error)")
}
}
static func imageInfo(_ diskURL: URL) throws -> ImageInfo {
do {
let (stdoutData, _) = try run([
"image", "info", "--plist",
diskURL.path
])
do {
return try PropertyListDecoder().decode(ImageInfo.self, from: stdoutData)
} catch {
throw RuntimeError.Generic("Failed to parse \"diskutil image info --plist\" output: \(error)")
}
}
}
private static func run(_ arguments: [String]) throws -> (Data, Data) {
guard let diskutilURL = resolveBinaryPath("diskutil") else {
throw RuntimeError.Generic("\"diskutil\" binary is not found in PATH")
}
let process = Process()
process.executableURL = diskutilURL
process.arguments = arguments
let stdoutPipe = Pipe()
process.standardOutput = stdoutPipe
let stderrPipe = Pipe()
process.standardError = stderrPipe
do {
try process.run()
} catch {
throw RuntimeError.Generic("\"\(arguments.joined(separator: " "))\" failed: \(error)")
}
process.waitUntilExit()
let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
if process.terminationStatus != 0 {
let stdoutString = String(data: stdoutData, encoding: .utf8) ?? ""
let stderrString = String(data: stderrData, encoding: .utf8) ?? ""
throw RuntimeError.Generic("\"\(arguments.joined(separator: " "))\" failed with exit code \(process.terminationStatus): \(firstNonEmptyLine(stderrString, stdoutString))")
}
return (stdoutData, stderrData)
}
private static func firstNonEmptyLine(_ outputs: String...) -> String {
for output in outputs {
for line in output.split(separator: "\n", omittingEmptySubsequences: false) {
if !line.isEmpty {
return String(line)
}
}
}
return ""
}
}
+12 -2
View File
@@ -4,18 +4,28 @@ public class ProgressObserver: NSObject {
@objc var progressToObserve: Progress @objc var progressToObserve: Progress
var observation: NSKeyValueObservation? var observation: NSKeyValueObservation?
var lastTimeUpdated = Date.now var lastTimeUpdated = Date.now
private var lastRenderedLine: String?
public init(_ progress: Progress) { public init(_ progress: Progress) {
progressToObserve = progress progressToObserve = progress
} }
func log(_ renderer: Logger) { 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 observation = observe(\.progressToObserve.fractionCompleted) { progress, _ in
let currentTime = Date.now let currentTime = Date.now
if self.progressToObserve.isFinished || currentTime.timeIntervalSince(self.lastTimeUpdated) >= 1.0 { if self.progressToObserve.isFinished || currentTime.timeIntervalSince(self.lastTimeUpdated) >= 1.0 {
self.lastTimeUpdated = currentTime 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)
} }
} }
} }
@@ -0,0 +1,42 @@
import Foundation
import Network
import NIOPosix
import GRPC
import Cirruslabs_TartGuestAgent_Apple_Swift
import Cirruslabs_TartGuestAgent_Grpc_Swift
class AgentResolver {
static func ResolveIP(_ controlSocketPath: String) async throws -> IPv4Address? {
do {
return try await resolveIP(controlSocketPath)
} catch let error as GRPCConnectionPoolError {
return nil
}
}
private static func resolveIP(_ controlSocketPath: String) async throws -> IPv4Address? {
// Create a gRPC channel connected to the VM's control socket
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
defer {
try! group.syncShutdownGracefully()
}
let channel = try GRPCChannelPool.with(
target: .unixDomainSocket(controlSocketPath),
transportSecurity: .plaintext,
eventLoopGroup: group,
)
defer {
try! channel.close().wait()
}
// Invoke ResolveIP() gRPC method
let callOptions = CallOptions(timeLimit: .timeout(.seconds(1)))
let agentAsyncClient = AgentAsyncClient(channel: channel)
let resolveIPCall = agentAsyncClient.makeResolveIpCall(ResolveIPRequest(), callOptions: callOptions)
let response = try await resolveIPCall.response
return IPv4Address(response.ip)
}
}
-75
View File
@@ -1,75 +0,0 @@
import Foundation
import Compression
class DiskV1: Disk {
private static let bufferSizeBytes = 4 * 1024 * 1024
private static let layerLimitBytes = 500 * 1000 * 1000
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer] {
var pushedLayers: [OCIManifestLayer] = []
// Open the disk file
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
var mappedDiskReadOffset = 0
// Compress the disk file as a single stream
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { (length: Int) -> Data? in
// Determine the size of the next chunk
let bytesRead = min(length, mappedDisk.count - mappedDiskReadOffset)
// Read the next uncompressed chunk
let data = mappedDisk.subdata(in: mappedDiskReadOffset ..< mappedDiskReadOffset + bytesRead)
// Advance the offset
mappedDiskReadOffset += bytesRead
// Provide the uncompressed chunk to the compressing filter
return data
}
// Cut the compressed stream into layers, each equal exactly ``Self.layerLimitBytes`` bytes,
// except for the last one, which may be smaller
while let compressedData = try compressingFilter.readData(ofLength: Self.layerLimitBytes) {
let layerDigest = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb)
pushedLayers.append(OCIManifestLayer(
mediaType: diskV1MediaType,
size: compressedData.count,
digest: layerDigest
))
// Update progress using an absolute value
progress.completedUnitCount = Int64(mappedDiskReadOffset)
}
return pushedLayers
}
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil, deduplicate: Bool = false) async throws {
if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
throw OCIError.FailedToCreateVmFile
}
// Open the disk file
let disk = try FileHandle(forWritingTo: diskURL)
defer { try! disk.close() }
// Decompress the layers onto the disk in a single stream
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
if let data = data {
try disk.write(contentsOf: data)
}
}
for diskLayer in diskLayers {
try await registry.pullBlob(diskLayer.digest) { data in
try filter.write(data)
// Update the progress
progress.completedUnitCount += Int64(data.count)
}
}
try filter.finalize()
}
}
+8 -1
View File
@@ -6,7 +6,6 @@ let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
// Layer media types // Layer media types
let configMediaType = "application/vnd.cirruslabs.tart.config.v1" let configMediaType = "application/vnd.cirruslabs.tart.config.v1"
let diskV1MediaType = "application/vnd.cirruslabs.tart.disk.v1"
let diskV2MediaType = "application/vnd.cirruslabs.tart.disk.v2" let diskV2MediaType = "application/vnd.cirruslabs.tart.disk.v2"
let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1" let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1"
@@ -14,6 +13,9 @@ let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1"
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size" let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time" let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time"
// Manifest labels
let diskFormatLabel = "org.cirruslabs.tart.disk.format"
// Layer annotations // Layer annotations
let uncompressedSizeAnnotation = "org.cirruslabs.tart.uncompressed-size" let uncompressedSizeAnnotation = "org.cirruslabs.tart.uncompressed-size"
let uncompressedContentDigestAnnotation = "org.cirruslabs.tart.uncompressed-content-digest" let uncompressedContentDigestAnnotation = "org.cirruslabs.tart.uncompressed-content-digest"
@@ -66,6 +68,11 @@ struct OCIManifest: Codable, Equatable {
struct OCIConfig: Codable { struct OCIConfig: Codable {
var architecture: Architecture = .arm64 var architecture: Architecture = .arm64
var os: OS = .darwin var os: OS = .darwin
var config: ConfigContainer?
struct ConfigContainer: Codable {
var Labels: [String: String]?
}
func toJSON() throws -> Data { func toJSON() throws -> Data {
try Config.jsonEncoder().encode(self) try Config.jsonEncoder().encode(self)
+6 -2
View File
@@ -429,8 +429,12 @@ class Registry {
} }
for provider in credentialsProviders { for provider in credentialsProviders {
if let (user, password) = try provider.retrieve(host: host) { do {
return (user, password) 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 return nil
+64
View File
@@ -0,0 +1,64 @@
import Foundation
import OpenTelemetryApi
import OpenTelemetrySdk
import OpenTelemetryProtocolExporterHttp
import ResourceExtension
class OTel {
let tracerProvider: TracerProviderSdk?
let tracer: Tracer
static let shared = OTel()
init() {
tracerProvider = Self.initializeTracing()
tracer = OpenTelemetry.instance.tracerProvider.get(instrumentationName: "tart", instrumentationVersion: CI.version)
}
static func initializeTracing() -> TracerProviderSdk? {
guard let _ = ProcessInfo.processInfo.environment["TRACEPARENT"] else {
return nil
}
var resource = DefaultResources().get()
resource.merge(other: Resource(attributes: [
SemanticConventions.Service.name.rawValue: .string("tart"),
SemanticConventions.Service.version.rawValue: .string(CI.version)
]))
let spanExporter: SpanExporter
if let endpointRaw = ProcessInfo.processInfo.environment["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"],
let endpoint = URL(string: endpointRaw) {
spanExporter = OtlpHttpTraceExporter(endpoint: endpoint)
} else {
spanExporter = OtlpHttpTraceExporter()
}
let spanProcessor = SimpleSpanProcessor(spanExporter: spanExporter)
let tracerProvider = TracerProviderBuilder()
.add(spanProcessor: spanProcessor)
.with(resource: resource)
.build()
OpenTelemetry.registerTracerProvider(tracerProvider: tracerProvider)
return tracerProvider
}
func flush() {
OpenTelemetry.instance.contextProvider.activeSpan?.end()
guard let tracerProvider else {
// No tracing was initialized, so just ending a span is enough
return
}
tracerProvider.forceFlush()
// Work around OpenTelemtry not flushing traces after explicitly asking it to do so
//
// [1]: https://github.com/open-telemetry/opentelemetry-swift/issues/685
// [2]: https://github.com/open-telemetry/opentelemetry-swift/issues/555
Thread.sleep(forTimeInterval: .fromMilliseconds(100))
}
}
+6 -1
View File
@@ -82,7 +82,7 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration { func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
let result = VZMacGraphicsDeviceConfiguration() 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) let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
result.displays = [ result.displays = [
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize) VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
@@ -127,6 +127,11 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
[VZUSBScreenCoordinatePointingDeviceConfiguration(), VZMacTrackpadConfiguration()] [VZUSBScreenCoordinatePointingDeviceConfiguration(), VZMacTrackpadConfiguration()]
} }
func pointingDevicesSimplified() -> [VZPointingDeviceConfiguration] {
// Only include the USB pointing device, not the trackpad
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
}
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] { func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
if #available(macOS 14, *) { if #available(macOS 14, *) {
return [VZMacTrackpadConfiguration()] return [VZMacTrackpadConfiguration()]
+5
View File
@@ -42,4 +42,9 @@ struct Linux: Platform {
func pointingDevices() -> [VZPointingDeviceConfiguration] { func pointingDevices() -> [VZPointingDeviceConfiguration] {
[VZUSBScreenCoordinatePointingDeviceConfiguration()] [VZUSBScreenCoordinatePointingDeviceConfiguration()]
} }
func pointingDevicesSimplified() -> [VZPointingDeviceConfiguration] {
// Linux doesn't support trackpad, so just return the regular pointing devices
return pointingDevices()
}
} }
+1
View File
@@ -7,6 +7,7 @@ protocol Platform: Codable {
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
func keyboards() -> [VZKeyboardConfiguration] func keyboards() -> [VZKeyboardConfiguration]
func pointingDevices() -> [VZPointingDeviceConfiguration] func pointingDevices() -> [VZPointingDeviceConfiguration]
func pointingDevicesSimplified() -> [VZPointingDeviceConfiguration]
} }
protocol PlatformSuspendable: Platform { protocol PlatformSuspendable: Platform {
+37 -40
View File
@@ -1,7 +1,9 @@
import ArgumentParser import ArgumentParser
import Darwin import Darwin
import Foundation import Foundation
import Sentry import OpenTelemetryApi
import OpenTelemetrySdk
import OpenTelemetryProtocolExporterHttp
@main @main
struct Root: AsyncParsableCommand { struct Root: AsyncParsableCommand {
@@ -18,6 +20,7 @@ struct Root: AsyncParsableCommand {
Login.self, Login.self,
Logout.self, Logout.self,
IP.self, IP.self,
Exec.self,
Pull.self, Pull.self,
Push.self, Push.self,
Import.self, Import.self,
@@ -30,39 +33,6 @@ struct Root: AsyncParsableCommand {
]) ])
public static func main() async throws { public static func main() async throws {
// Initialize Sentry
if let dsn = ProcessInfo.processInfo.environment["SENTRY_DSN"] {
SentrySDK.start { options in
options.dsn = dsn
options.releaseName = CI.release
options.tracesSampleRate = Float(
ProcessInfo.processInfo.environment["SENTRY_TRACES_SAMPLE_RATE"] ?? "1.0"
) as NSNumber?
// By default only 5XX are captured
// Let's capture everything but 401 (unauthorized)
options.enableCaptureFailedRequests = true
options.failedRequestStatusCodes = [
HttpStatusCodeRange(min: 400, max: 400),
HttpStatusCodeRange(min: 402, max: 599)
]
}
}
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)
}
}
}
// Add commands that are only available on specific macOS versions // Add commands that are only available on specific macOS versions
if #available(macOS 14, *) { if #available(macOS 14, *) {
configuration.subcommands.append(Suspend.self) configuration.subcommands.append(Suspend.self)
@@ -82,33 +52,60 @@ struct Root: AsyncParsableCommand {
// Set line-buffered output for stdout // Set line-buffered output for stdout
setlinebuf(stdout) setlinebuf(stdout)
// Parse and run command defer { OTel.shared.flush() }
do { do {
// Parse command
var command = try parseAsRoot() var command = try parseAsRoot()
// Create a root span for the command we're about to run
let span = OTel.shared.tracer.spanBuilder(spanName: type(of: command)._commandName).startSpan()
defer { span.end() }
OpenTelemetry.instance.contextProvider.setActiveSpan(span)
// Enrich root command span with command's arguments
let commandLineArguments = ProcessInfo.processInfo.arguments.map { argument in
AttributeValue.string(argument)
}
span.setAttribute(key: "Command-line arguments", value: .array(AttributeArray(values: commandLineArguments)))
// Enrich root command span with Cirrus CI-specific tags
if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
for (key, value) in tags.split(separator: ",").compactMap(splitEnvironmentVariable) {
span.setAttribute(key: key, value: .string(value))
}
}
// Run garbage-collection before each command (shouldn't take too long) // Run garbage-collection before each command (shouldn't take too long)
if type(of: command) != type(of: Pull()) && type(of: command) != type(of: Clone()){ if type(of: command) != type(of: Pull()) && type(of: command) != type(of: Clone()){
do { do {
try Config().gc() try Config().gc()
} catch { } catch {
fputs("Failed to perform garbage collection!\n\(error)\n", stderr) fputs("Failed to perform garbage collection: \(error)\n", stderr)
} }
} }
// Run command
if var asyncCommand = command as? AsyncParsableCommand { if var asyncCommand = command as? AsyncParsableCommand {
try await asyncCommand.run() try await asyncCommand.run()
} else { } else {
try command.run() try command.run()
} }
} catch { } catch {
// Capture the error into Sentry // Not an error, just a custom exit code from "tart exec"
SentrySDK.capture(error: error) if let execCustomExitCodeError = error as? ExecCustomExitCodeError {
SentrySDK.flush(timeout: 2.seconds.timeInterval) OTel.shared.flush()
Foundation.exit(execCustomExitCodeError.exitCode)
}
// Capture the error into OpenTelemetry
OpenTelemetry.instance.contextProvider.activeSpan?.recordException(error)
// Handle a non-ArgumentParser's exception that requires a specific exit code to be set // Handle a non-ArgumentParser's exception that requires a specific exit code to be set
if let errorWithExitCode = error as? HasExitCode { if let errorWithExitCode = error as? HasExitCode {
fputs("\(error)\n", stderr) fputs("\(error)\n", stderr)
OTel.shared.flush()
Foundation.exit(errorWithExitCode.exitCode) Foundation.exit(errorWithExitCode.exitCode)
} }
@@ -117,7 +114,7 @@ struct Root: AsyncParsableCommand {
} }
} }
private static func parseCirrusSentryTag(_ tag: String.SubSequence) -> (String, String)? { private static func splitEnvironmentVariable(_ tag: String.SubSequence) -> (String, String)? {
let splits = tag.split(separator: "=", maxSplits: 1) let splits = tag.split(separator: "=", maxSplits: 1)
if splits.count != 2 { if splits.count != 2 {
return nil return nil
@@ -5,7 +5,7 @@ fileprivate func normalizeName(_ name: String) -> String {
return name.replacingOccurrences(of: ":", with: "\\:") return name.replacingOccurrences(of: ":", with: "\\:")
} }
func completeMachines(_ arguments: [String]) -> [String] { func completeMachines(_ arguments: [String], _ argumentIdx: Int, _ argumentPrefix: String) -> [String] {
let localVMs = (try? VMStorageLocal().list().map { name, _ in let localVMs = (try? VMStorageLocal().list().map { name, _ in
normalizeName(name) normalizeName(name)
}) ?? [] }) ?? []
@@ -15,12 +15,12 @@ func completeMachines(_ arguments: [String]) -> [String] {
return (localVMs + ociVMs) return (localVMs + ociVMs)
} }
func completeLocalMachines(_ arguments: [String]) -> [String] { func completeLocalMachines(_ arguments: [String], _ argumentIdx: Int, _ argumentPrefix: String) -> [String] {
let localVMs = (try? VMStorageLocal().list()) ?? [] let localVMs = (try? VMStorageLocal().list()) ?? []
return localVMs.map { name, _ in normalizeName(name) } return localVMs.map { name, _ in normalizeName(name) }
} }
func completeRunningMachines(_ arguments: [String]) -> [String] { func completeRunningMachines(_ arguments: [String], _ argumentIdx: Int, _ argumentPrefix: String) -> [String] {
let localVMs = (try? VMStorageLocal().list()) ?? [] let localVMs = (try? VMStorageLocal().list()) ?? []
return localVMs return localVMs
.filter { _, vmDir in (try? vmDir.state() == .Running) ?? false} .filter { _, vmDir in (try? vmDir.state() == .Running) ?? false}
+60
View File
@@ -0,0 +1,60 @@
import Foundation
import System
struct State {
fileprivate let termios: termios
}
class Term {
static func IsTerminal() -> Bool {
var termios = termios()
return tcgetattr(FileHandle.standardInput.fileDescriptor, &termios) != -1
}
static func MakeRaw() throws -> State {
var termiosOrig = termios()
var ret = tcgetattr(FileHandle.standardInput.fileDescriptor, &termiosOrig)
if ret == -1 {
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.TerminalOperationFailed("failed to retrieve terminal parameters: \(details)")
}
var termiosRaw = termiosOrig
cfmakeraw(&termiosRaw)
ret = tcsetattr(FileHandle.standardInput.fileDescriptor, TCSANOW, &termiosRaw)
if ret == -1 {
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.TerminalOperationFailed("failed to set terminal parameters: \(details)")
}
return State(termios: termiosOrig)
}
static func Restore(_ state: State) throws {
var termios = state.termios
let ret = tcsetattr(FileHandle.standardInput.fileDescriptor, TCSANOW, &termios)
if ret == -1 {
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.TerminalOperationFailed("failed to set terminal parameters: \(details)")
}
}
static func GetSize() throws -> (width: UInt16, height: UInt16) {
var winsize = winsize()
guard ioctl(STDOUT_FILENO, TIOCGWINSZ, &winsize) != -1 else {
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.TerminalOperationFailed("failed to get terminal size: \(details)")
}
return (width: winsize.ws_col, height: winsize.ws_row)
}
}
+59 -20
View File
@@ -50,7 +50,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
audio: Bool = true, audio: Bool = true,
clipboard: Bool = true, clipboard: Bool = true,
sync: VZDiskImageSynchronizationMode = .full, sync: VZDiskImageSynchronizationMode = .full,
caching: VZDiskImageCachingMode? = nil caching: VZDiskImageCachingMode? = nil,
noTrackpad: Bool = false,
noPointer: Bool = false,
noKeyboard: Bool = false
) throws { ) throws {
name = vmDir.name name = vmDir.name
config = try VMConfig.init(fromURL: vmDir.configURL) config = try VMConfig.init(fromURL: vmDir.configURL)
@@ -71,7 +74,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
audio: audio, audio: audio,
clipboard: clipboard, clipboard: clipboard,
sync: sync, sync: sync,
caching: caching caching: caching,
noTrackpad: noTrackpad,
noPointer: noPointer,
noKeyboard: noKeyboard
) )
virtualMachine = VZVirtualMachine(configuration: configuration) virtualMachine = VZVirtualMachine(configuration: configuration)
@@ -141,6 +147,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
vmDir: VMDirectory, vmDir: VMDirectory,
ipswURL: URL, ipswURL: URL,
diskSizeGB: UInt16, diskSizeGB: UInt16,
diskFormat: DiskImageFormat = .raw,
network: Network = NetworkShared(), network: Network = NetworkShared(),
additionalStorageDevices: [VZStorageDeviceConfiguration] = [], additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [], directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
@@ -173,14 +180,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
_ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel) _ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
// Create disk // Create disk
try vmDir.resizeDisk(diskSizeGB) try vmDir.resizeDisk(diskSizeGB, format: diskFormat)
name = vmDir.name name = vmDir.name
// Create config // Create config
config = VMConfig( config = VMConfig(
platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel), platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
cpuCountMin: requirements.minimumSupportedCPUCount, cpuCountMin: requirements.minimumSupportedCPUCount,
memorySizeMin: requirements.minimumSupportedMemorySize memorySizeMin: requirements.minimumSupportedMemorySize,
diskFormat: diskFormat
) )
// allocate at least 4 CPUs because otherwise VMs are frequently freezing // allocate at least 4 CPUs because otherwise VMs are frequently freezing
try config.setCPU(cpuCount: max(4, requirements.minimumSupportedCPUCount)) try config.setCPU(cpuCount: max(4, requirements.minimumSupportedCPUCount))
@@ -222,15 +230,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
#endif #endif
@available(macOS 13, *) @available(macOS 13, *)
static func linux(vmDir: VMDirectory, diskSizeGB: UInt16) async throws -> VM { static func linux(vmDir: VMDirectory, diskSizeGB: UInt16, diskFormat: DiskImageFormat = .raw) async throws -> VM {
// Create NVRAM // Create NVRAM
_ = try VZEFIVariableStore(creatingVariableStoreAt: vmDir.nvramURL) _ = try VZEFIVariableStore(creatingVariableStoreAt: vmDir.nvramURL)
// Create disk // Create disk
try vmDir.resizeDisk(diskSizeGB) try vmDir.resizeDisk(diskSizeGB, format: diskFormat)
// Create config // Create config
let config = VMConfig(platform: Linux(), cpuCountMin: 4, memorySizeMin: 4096 * 1024 * 1024) let config = VMConfig(platform: Linux(), cpuCountMin: 4, memorySizeMin: 4096 * 1024 * 1024, diskFormat: diskFormat)
try config.save(toURL: vmDir.configURL) try config.save(toURL: vmDir.configURL)
return try VM(vmDir: vmDir) return try VM(vmDir: vmDir)
@@ -246,6 +254,19 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
} }
} }
@MainActor
func connect(toPort: UInt32) async throws -> VZVirtioSocketConnection {
guard let socketDevice = virtualMachine.socketDevices.first else {
throw RuntimeError.VMSocketFailed(toPort, ", VM has no socket devices configured")
}
guard let virtioSocketDevice = socketDevice as? VZVirtioSocketDevice else {
throw RuntimeError.VMSocketFailed(toPort, ", expected VM's first socket device to have a type of VZVirtioSocketDevice, got \(type(of: socketDevice)) instead")
}
return try await virtioSocketDevice.connect(toPort: toPort)
}
func run() async throws { func run() async throws {
do { do {
try await sema.waitUnlessCancelled() try await sema.waitUnlessCancelled()
@@ -298,7 +319,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
audio: Bool = true, audio: Bool = true,
clipboard: Bool = true, clipboard: Bool = true,
sync: VZDiskImageSynchronizationMode = .full, sync: VZDiskImageSynchronizationMode = .full,
caching: VZDiskImageCachingMode? = nil caching: VZDiskImageCachingMode? = nil,
noTrackpad: Bool = false,
noPointer: Bool = false,
noKeyboard: Bool = false
) throws -> VZVirtualMachineConfiguration { ) throws -> VZVirtualMachineConfiguration {
let configuration = VZVirtualMachineConfiguration() let configuration = VZVirtualMachineConfiguration()
@@ -338,8 +362,20 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
configuration.keyboards = platformSuspendable.keyboardsSuspendable() configuration.keyboards = platformSuspendable.keyboardsSuspendable()
configuration.pointingDevices = platformSuspendable.pointingDevicesSuspendable() configuration.pointingDevices = platformSuspendable.pointingDevicesSuspendable()
} else { } else {
configuration.keyboards = vmConfig.platform.keyboards()
configuration.pointingDevices = vmConfig.platform.pointingDevices() 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()
}
} }
// Networking // Networking
@@ -351,17 +387,19 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
} }
// Clipboard sharing via Spice agent // Clipboard sharing via Spice agent
if clipboard && vmConfig.os == .linux { if clipboard {
let spiceAgentConsoleDevice = VZVirtioConsoleDeviceConfiguration() let spiceAgentConsoleDevice = VZVirtioConsoleDeviceConfiguration()
let spiceAgentPort = VZVirtioConsolePortConfiguration() let spiceAgentPort = VZVirtioConsolePortConfiguration()
spiceAgentPort.name = VZSpiceAgentPortAttachment.spiceAgentPortName spiceAgentPort.name = VZSpiceAgentPortAttachment.spiceAgentPortName
spiceAgentPort.attachment = VZSpiceAgentPortAttachment() let spiceAgentPortAttachment = VZSpiceAgentPortAttachment()
spiceAgentPortAttachment.sharesClipboard = true
spiceAgentPort.attachment = spiceAgentPortAttachment
spiceAgentConsoleDevice.ports[0] = spiceAgentPort spiceAgentConsoleDevice.ports[0] = spiceAgentPort
configuration.consoleDevices.append(spiceAgentConsoleDevice) configuration.consoleDevices.append(spiceAgentConsoleDevice)
} }
// Storage // Storage
let attachment: VZDiskImageStorageDeviceAttachment = try VZDiskImageStorageDeviceAttachment( var attachment = try VZDiskImageStorageDeviceAttachment(
url: diskURL, url: diskURL,
readOnly: false, readOnly: false,
// When not specified, use "cached" caching mode for Linux VMs to prevent file-system corruption[1] // When not specified, use "cached" caching mode for Linux VMs to prevent file-system corruption[1]
@@ -390,15 +428,16 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// //
// A dummy console device useful for implementing // A dummy console device useful for implementing
// host feature checks in the guest agent software. // host feature checks in the guest agent software.
if !suspendable { let consolePort = VZVirtioConsolePortConfiguration()
let consolePort = VZVirtioConsolePortConfiguration() consolePort.name = "tart-version-\(CI.version)"
consolePort.name = "tart-version-\(CI.version)"
let consoleDevice = VZVirtioConsoleDeviceConfiguration() let consoleDevice = VZVirtioConsoleDeviceConfiguration()
consoleDevice.ports[0] = consolePort consoleDevice.ports[0] = consolePort
configuration.consoleDevices.append(consoleDevice) configuration.consoleDevices.append(consoleDevice)
}
// Socket device
configuration.socketDevices = [VZVirtioSocketDeviceConfiguration()]
try configuration.validate() try configuration.validate()
+20 -3
View File
@@ -25,20 +25,31 @@ enum CodingKeys: String, CodingKey {
case macAddress case macAddress
case display case display
case displayRefit case displayRefit
case diskFormat
// macOS-specific keys // macOS-specific keys
case ecid case ecid
case hardwareModel case hardwareModel
} }
struct VMDisplayConfig: Codable { struct VMDisplayConfig: Codable, Equatable {
enum Unit: String, Codable {
case point = "pt"
case pixel = "px"
}
var width: Int = 1024 var width: Int = 1024
var height: Int = 768 var height: Int = 768
var unit: Unit?
} }
extension VMDisplayConfig: CustomStringConvertible { extension VMDisplayConfig: CustomStringConvertible {
var description: String { var description: String {
"\(width)x\(height)" if let unit {
"\(width)x\(height)\(unit.rawValue)"
} else {
"\(width)x\(height)"
}
} }
} }
@@ -54,12 +65,14 @@ struct VMConfig: Codable {
var macAddress: VZMACAddress var macAddress: VZMACAddress
var display: VMDisplayConfig = VMDisplayConfig() var display: VMDisplayConfig = VMDisplayConfig()
var displayRefit: Bool? var displayRefit: Bool?
var diskFormat: DiskImageFormat = .raw
init( init(
platform: Platform, platform: Platform,
cpuCountMin: Int, cpuCountMin: Int,
memorySizeMin: UInt64, memorySizeMin: UInt64,
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered() macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered(),
diskFormat: DiskImageFormat = .raw
) { ) {
self.os = platform.os() self.os = platform.os()
self.arch = CurrentArchitecture() self.arch = CurrentArchitecture()
@@ -67,6 +80,7 @@ struct VMConfig: Codable {
self.macAddress = macAddress self.macAddress = macAddress
self.cpuCountMin = cpuCountMin self.cpuCountMin = cpuCountMin
self.memorySizeMin = memorySizeMin self.memorySizeMin = memorySizeMin
self.diskFormat = diskFormat
cpuCount = cpuCountMin cpuCount = cpuCountMin
memorySize = memorySizeMin memorySize = memorySizeMin
} }
@@ -124,6 +138,8 @@ struct VMConfig: Codable {
display = try container.decodeIfPresent(VMDisplayConfig.self, forKey: .display) ?? VMDisplayConfig() display = try container.decodeIfPresent(VMDisplayConfig.self, forKey: .display) ?? VMDisplayConfig()
displayRefit = try container.decodeIfPresent(Bool.self, forKey: .displayRefit) displayRefit = try container.decodeIfPresent(Bool.self, forKey: .displayRefit)
let diskFormatString = try container.decodeIfPresent(String.self, forKey: .diskFormat) ?? "raw"
diskFormat = DiskImageFormat(rawValue: diskFormatString) ?? .raw
} }
func encode(to encoder: Encoder) throws { func encode(to encoder: Encoder) throws {
@@ -142,6 +158,7 @@ struct VMConfig: Codable {
if let displayRefit = displayRefit { if let displayRefit = displayRefit {
try container.encode(displayRefit, forKey: .displayRefit) try container.encode(displayRefit, forKey: .displayRefit)
} }
try container.encode(diskFormat.rawValue, forKey: .diskFormat)
} }
mutating func setCPU(cpuCount: Int) throws { mutating func setCPU(cpuCount: Int) throws {
+24 -25
View File
@@ -1,6 +1,8 @@
import Compression import Compression
import Foundation import Foundation
import Sentry import OpenTelemetryApi
let legacyDiskV1MediaType = "application/vnd.cirruslabs.tart.disk.v1"
enum OCIError: Error { enum OCIError: Error {
case ShouldBeExactlyOneLayer case ShouldBeExactlyOneLayer
@@ -29,21 +31,20 @@ extension VMDirectory {
try configFile.close() try configFile.close()
// Pull VM's disk layers and decompress them into a disk file // Pull VM's disk layers and decompress them into a disk file
let diskImplType: Disk.Type if manifest.layers.contains(where: { $0.mediaType == legacyDiskV1MediaType }) {
let layers: [OCIManifestLayer] throw RuntimeError.Generic("Pulling OCI images with legacy disk media type \(legacyDiskV1MediaType) is no longer supported, please re-push the image using a current Tart version")
}
if manifest.layers.contains(where: { $0.mediaType == diskV1MediaType }) { let layers = manifest.layers.filter { $0.mediaType == diskV2MediaType }
diskImplType = DiskV1.self if layers.isEmpty {
layers = manifest.layers.filter { $0.mediaType == diskV1MediaType }
} else if manifest.layers.contains(where: { $0.mediaType == diskV2MediaType }) {
diskImplType = DiskV2.self
layers = manifest.layers.filter { $0.mediaType == diskV2MediaType }
} else {
throw OCIError.ShouldBeAtLeastOneLayer throw OCIError.ShouldBeAtLeastOneLayer
} }
let diskCompressedSize = layers.map { Int64($0.size) }.reduce(0, +) let diskCompressedSize = layers.map { Int64($0.size) }.reduce(0, +)
SentrySDK.span?.setMeasurement(name: "compressed_disk_size", value: diskCompressedSize as NSNumber, unit: MeasurementUnitInformation.byte) OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
key: "compressed_disk_size_bytes",
value: .int(Int(diskCompressedSize))
)
let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0) let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0)
defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...") defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...")
@@ -52,10 +53,10 @@ extension VMDirectory {
ProgressObserver(progress).log(defaultLogger) ProgressObserver(progress).log(defaultLogger)
do { do {
try await diskImplType.pull(registry: registry, diskLayers: layers, diskURL: diskURL, try await DiskV2.pull(registry: registry, diskLayers: layers, diskURL: diskURL,
concurrency: concurrency, progress: progress, concurrency: concurrency, progress: progress,
localLayerCache: localLayerCache, localLayerCache: localLayerCache,
deduplicate: deduplicate) deduplicate: deduplicate)
} catch let error where error is FilterError { } catch let error where error is FilterError {
throw RuntimeError.PullFailed("failed to decompress disk: \(error.localizedDescription)") throw RuntimeError.PullFailed("failed to decompress disk: \(error.localizedDescription)")
} }
@@ -87,11 +88,15 @@ extension VMDirectory {
try manifest.toJSON().write(to: manifestURL) try manifest.toJSON().write(to: manifestURL)
} }
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, diskFormat: String, concurrency: UInt) async throws -> RemoteName { func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, concurrency: UInt, labels: [String: String] = [:]) async throws -> RemoteName {
var layers = Array<OCIManifestLayer>() var layers = Array<OCIManifestLayer>()
// Read VM's config and push it as blob // Read VM's config and push it as blob
let config = try VMConfig(fromURL: configURL) let config = try VMConfig(fromURL: configURL)
// Add disk format label automatically
var labels = labels
labels[diskFormatLabel] = config.diskFormat.rawValue
let configJSON = try JSONEncoder().encode(config) let configJSON = try JSONEncoder().encode(config)
defaultLogger.appendNewLine("pushing config...") defaultLogger.appendNewLine("pushing config...")
let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb) let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb)
@@ -104,14 +109,7 @@ extension VMDirectory {
let progress = Progress(totalUnitCount: diskSize) let progress = Progress(totalUnitCount: diskSize)
ProgressObserver(progress).log(defaultLogger) ProgressObserver(progress).log(defaultLogger)
switch diskFormat { layers.append(contentsOf: try await DiskV2.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, concurrency: concurrency, progress: progress))
case "v1":
layers.append(contentsOf: try await DiskV1.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, concurrency: concurrency, progress: progress))
case "v2":
layers.append(contentsOf: try await DiskV2.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, concurrency: concurrency, progress: progress))
default:
throw RuntimeError.OCIUnsupportedDiskFormat(diskFormat)
}
// Read VM's NVRAM and push it as blob // Read VM's NVRAM and push it as blob
defaultLogger.appendNewLine("pushing NVRAM...") defaultLogger.appendNewLine("pushing NVRAM...")
@@ -121,7 +119,8 @@ extension VMDirectory {
layers.append(OCIManifestLayer(mediaType: nvramMediaType, size: nvram.count, digest: nvramDigest)) layers.append(OCIManifestLayer(mediaType: nvramMediaType, size: nvram.count, digest: nvramDigest))
// Craft a stub OCI config for Docker Hub compatibility // Craft a stub OCI config for Docker Hub compatibility
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON() let ociConfigContainer = OCIConfig.ConfigContainer(Labels: labels)
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os, config: ociConfigContainer).toJSON()
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb) let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb)
let manifest = OCIManifest( let manifest = OCIManifest(
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest), config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
+120 -4
View File
@@ -26,6 +26,9 @@ struct VMDirectory: Prunable {
var manifestURL: URL { var manifestURL: URL {
baseURL.appendingPathComponent("manifest.json") baseURL.appendingPathComponent("manifest.json")
} }
var controlSocketURL: URL {
URL(fileURLWithPath: "control.sock", relativeTo: baseURL)
}
var explicitlyPulledMark: URL { var explicitlyPulledMark: URL {
baseURL.appendingPathComponent(".explicitly-pulled") baseURL.appendingPathComponent(".explicitly-pulled")
@@ -139,14 +142,34 @@ struct VMDirectory: Prunable {
try vmConfig.save(toURL: configURL) try vmConfig.save(toURL: configURL)
} }
func resizeDisk(_ sizeGB: UInt16) throws { func resizeDisk(_ sizeGB: UInt16, format: DiskImageFormat = .raw) throws {
if !FileManager.default.fileExists(atPath: diskURL.path) { let diskExists = FileManager.default.fileExists(atPath: diskURL.path)
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
}
if diskExists {
// Existing disk - resize it
try resizeExistingDisk(sizeGB)
} else {
// New disk - create it with the specified format
try createDisk(sizeGB: sizeGB, format: format)
}
}
private func resizeExistingDisk(_ sizeGB: UInt16) throws {
// Check if this is an ASIF disk by reading the VM config
let vmConfig = try VMConfig(fromURL: configURL)
if vmConfig.diskFormat == .asif {
try resizeASIFDisk(sizeGB)
} else {
try resizeRawDisk(sizeGB)
}
}
private func resizeRawDisk(_ sizeGB: UInt16) throws {
let diskFileHandle = try FileHandle.init(forWritingTo: diskURL) let diskFileHandle = try FileHandle.init(forWritingTo: diskURL)
let currentDiskFileLength = try diskFileHandle.seekToEnd() let currentDiskFileLength = try diskFileHandle.seekToEnd()
let desiredDiskFileLength = UInt64(sizeGB) * 1000 * 1000 * 1000 let desiredDiskFileLength = UInt64(sizeGB) * 1000 * 1000 * 1000
if desiredDiskFileLength < currentDiskFileLength { if desiredDiskFileLength < currentDiskFileLength {
let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentDiskFileLength)) let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentDiskFileLength))
let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredDiskFileLength)) let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredDiskFileLength))
@@ -158,6 +181,84 @@ struct VMDirectory: Prunable {
try diskFileHandle.close() try diskFileHandle.close()
} }
private func resizeASIFDisk(_ sizeGB: UInt16) throws {
do {
let diskImageInfo = try Diskutil.imageInfo(diskURL)
let currentSizeBytes = try diskImageInfo.totalBytes()
let desiredSizeBytes = UInt64(sizeGB) * 1000 * 1000 * 1000
if desiredSizeBytes < currentSizeBytes {
let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentSizeBytes))
let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredSizeBytes))
throw RuntimeError.InvalidDiskSize("New disk size of \(desiredLengthHuman) should be larger " +
"than the current disk size of \(currentLengthHuman)")
} else if desiredSizeBytes > currentSizeBytes {
// Resize the ASIF disk image using diskutil
try performASIFResize(sizeGB)
} else {
// If sizes are equal, no action needed
}
} catch let error as RuntimeError {
throw error
} catch {
throw RuntimeError.FailedToResizeDisk("\(error)")
}
}
private func performASIFResize(_ sizeGB: UInt16) throws {
guard let diskutilURL = resolveBinaryPath("diskutil") else {
throw RuntimeError.FailedToResizeDisk("diskutil not found in PATH")
}
let process = Process()
process.executableURL = diskutilURL
process.arguments = [
"image", "resize",
"--size", "\(sizeGB)G",
diskURL.path
]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
if process.terminationStatus != 0 {
let output = String(data: data, encoding: .utf8) ?? "Unknown error"
throw RuntimeError.FailedToResizeDisk("Failed to resize ASIF disk image: \(output)")
}
} catch {
throw RuntimeError.FailedToResizeDisk("Failed to execute diskutil resize: \(error)")
}
}
private func createDisk(sizeGB: UInt16, format: DiskImageFormat) throws {
switch format {
case .raw:
try createRawDisk(sizeGB: sizeGB)
case .asif:
try Diskutil.imageCreate(diskURL: diskURL, sizeGB: sizeGB)
}
}
private func createRawDisk(sizeGB: UInt16) throws {
// Create traditional raw disk image
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
let diskFileHandle = try FileHandle.init(forWritingTo: diskURL)
let desiredDiskFileLength = UInt64(sizeGB) * 1000 * 1000 * 1000
try diskFileHandle.truncate(atOffset: desiredDiskFileLength)
try diskFileHandle.close()
}
func delete() throws { func delete() throws {
let lock = try lock() let lock = try lock()
@@ -198,6 +299,21 @@ struct VMDirectory: Prunable {
try sizeBytes() / 1000 / 1000 / 1000 try sizeBytes() / 1000 / 1000 / 1000
} }
func diskSizeBytes() throws -> Int {
let vmConfig = try VMConfig(fromURL: configURL)
return switch vmConfig.diskFormat {
case .raw:
try sizeBytes()
case .asif:
try Diskutil.imageInfo(diskURL).totalBytes()
}
}
func diskSizeGB() throws -> Int {
try diskSizeBytes() / 1000 / 1000 / 1000
}
func markExplicitlyPulled() { func markExplicitlyPulled() {
FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil) FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil)
} }
+15 -14
View File
@@ -49,6 +49,7 @@ extension Error {
} }
enum RuntimeError : Error { enum RuntimeError : Error {
case Generic(_ message: String)
case VMConfigurationError(_ message: String) case VMConfigurationError(_ message: String)
case VMDoesNotExist(name: String) case VMDoesNotExist(name: String)
case VMMissingFiles(_ message: String) case VMMissingFiles(_ message: String)
@@ -59,6 +60,8 @@ enum RuntimeError : Error {
case DiskAlreadyInUse(_ message: String) case DiskAlreadyInUse(_ message: String)
case FailedToOpenBlockDevice(_ path: String, _ explanation: String) case FailedToOpenBlockDevice(_ path: String, _ explanation: String)
case InvalidDiskSize(_ message: String) case InvalidDiskSize(_ message: String)
case FailedToCreateDisk(_ message: String)
case FailedToResizeDisk(_ message: String)
case FailedToUpdateAccessDate(_ message: String) case FailedToUpdateAccessDate(_ message: String)
case PIDLockFailed(_ message: String) case PIDLockFailed(_ message: String)
case PIDLockMissing(_ message: String) case PIDLockMissing(_ message: String)
@@ -71,10 +74,11 @@ enum RuntimeError : Error {
case ImportFailed(_ message: String) case ImportFailed(_ message: String)
case SoftnetFailed(_ message: String) case SoftnetFailed(_ message: String)
case OCIStorageError(_ message: String) case OCIStorageError(_ message: String)
case OCIUnsupportedDiskFormat(_ format: String)
case SuspendFailed(_ message: String) case SuspendFailed(_ message: String)
case PullFailed(_ message: String) case PullFailed(_ message: String)
case VirtualMachineLimitExceeded(_ hint: String) case VirtualMachineLimitExceeded(_ hint: String)
case VMSocketFailed(_ port: UInt32, _ explanation: String)
case TerminalOperationFailed(_ message: String)
} }
protocol HasExitCode { protocol HasExitCode {
@@ -84,6 +88,8 @@ protocol HasExitCode {
extension RuntimeError : CustomStringConvertible { extension RuntimeError : CustomStringConvertible {
public var description: String { public var description: String {
switch self { switch self {
case .Generic(let message):
return message
case .VMConfigurationError(let message): case .VMConfigurationError(let message):
return message return message
case .VMDoesNotExist(let name): case .VMDoesNotExist(let name):
@@ -104,6 +110,10 @@ extension RuntimeError : CustomStringConvertible {
return "failed to open block device \(path): \(explanation)" return "failed to open block device \(path): \(explanation)"
case .InvalidDiskSize(let message): case .InvalidDiskSize(let message):
return message return message
case .FailedToCreateDisk(let message):
return message
case .FailedToResizeDisk(let message):
return message
case .FailedToUpdateAccessDate(let message): case .FailedToUpdateAccessDate(let message):
return message return message
case .PIDLockFailed(let message): case .PIDLockFailed(let message):
@@ -128,14 +138,16 @@ extension RuntimeError : CustomStringConvertible {
return "Softnet failed: \(message)" return "Softnet failed: \(message)"
case .OCIStorageError(let message): case .OCIStorageError(let message):
return "OCI storage error: \(message)" return "OCI storage error: \(message)"
case .OCIUnsupportedDiskFormat(let format):
return "OCI disk format \(format) is not supported by this version of Tart"
case .SuspendFailed(let message): case .SuspendFailed(let message):
return "Failed to suspend the VM: \(message)" return "Failed to suspend the VM: \(message)"
case .PullFailed(let message): case .PullFailed(let message):
return message return message
case .VirtualMachineLimitExceeded(let hint): case .VirtualMachineLimitExceeded(let hint):
return "The number of VMs exceeds the system limit\(hint)" return "The number of VMs exceeds the system limit\(hint)"
case .VMSocketFailed(let port, let explanation):
return "Failed to establish a VM socket connection to port \(port): \(explanation)"
case .TerminalOperationFailed(let message):
return message
} }
} }
} }
@@ -154,14 +166,3 @@ extension RuntimeError : HasExitCode {
} }
} }
} }
// Customize error description for Sentry[1]
//
// [1]: https://docs.sentry.io/platforms/apple/guides/ios/usage/#customizing-error-descriptions
extension RuntimeError : CustomNSError {
var errorUserInfo: [String : Any] {
[
NSDebugDescriptionErrorKey: description,
]
}
}
+5 -1
View File
@@ -1,7 +1,11 @@
import Foundation import Foundation
class VMStorageLocal: PrunableStorage { 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 { private func vmURL(_ name: String) -> URL {
baseURL.appendingPathComponent(name, isDirectory: true) baseURL.appendingPathComponent(name, isDirectory: true)
+23 -13
View File
@@ -1,9 +1,13 @@
import Foundation import Foundation
import Sentry import OpenTelemetryApi
import Retry import Retry
class VMStorageOCI: PrunableStorage { 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 { private func vmURL(_ name: RemoteName) -> URL {
baseURL.appendingRemoteName(name) baseURL.appendingRemoteName(name)
@@ -27,12 +31,12 @@ class VMStorageOCI: PrunableStorage {
return digest return digest
} }
func open(_ name: RemoteName) throws -> VMDirectory { func open(_ name: RemoteName, _ accessDate: Date = Date()) throws -> VMDirectory {
let vmDir = VMDirectory(baseURL: vmURL(name)) let vmDir = VMDirectory(baseURL: vmURL(name))
try vmDir.validate(userFriendlyName: name.description) try vmDir.validate(userFriendlyName: name.description)
try vmDir.baseURL.updateAccessDate() try vmDir.baseURL.updateAccessDate(accessDate)
return vmDir return vmDir
} }
@@ -141,9 +145,10 @@ class VMStorageOCI: PrunableStorage {
} }
func pull(_ name: RemoteName, registry: Registry, concurrency: UInt, deduplicate: Bool) async throws { func pull(_ name: RemoteName, registry: Registry, concurrency: UInt, deduplicate: Bool) async throws {
SentrySDK.configureScope { scope in OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
scope.setContext(value: ["imageName": name.description], key: "OCI") key: "oci.image-name",
} value: .string(name.description)
)
defaultLogger.appendNewLine("pulling manifest...") defaultLogger.appendNewLine("pulling manifest...")
@@ -177,18 +182,25 @@ class VMStorageOCI: PrunableStorage {
} }
if !exists(digestName) { if !exists(digestName) {
let transaction = SentrySDK.startTransaction(name: name.description, operation: "pull", bindToScope: true) let span = OTel.shared.tracer.spanBuilder(spanName: "pull").setActive(true).startSpan()
defer { span.end() }
let tmpVMDir = try VMDirectory.temporaryDeterministic(key: name.description) let tmpVMDir = try VMDirectory.temporaryDeterministic(key: name.description)
// Open an existing VM directory corresponding to this name, if any,
// marking it as outdated to speed up the garbage collection process
_ = try? open(name, Date(timeIntervalSince1970: 0))
// Lock the temporary VM directory to prevent it's garbage collection // Lock the temporary VM directory to prevent it's garbage collection
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL) let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
try tmpVMDirLock.lock() try tmpVMDirLock.lock()
// Try to reclaim some cache space if we know the VM size in advance // Try to reclaim some cache space if we know the VM size in advance
if let uncompressedDiskSize = manifest.uncompressedDiskSize() { if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
SentrySDK.configureScope { scope in OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
scope.setContext(value: ["imageUncompressedDiskSize": uncompressedDiskSize], key: "OCI") key: "oci.image-uncompressed-disk-size-bytes",
} value: .int(Int(uncompressedDiskSize))
)
let otherVMFilesSize: UInt64 = 128 * 1024 * 1024 let otherVMFilesSize: UInt64 = 128 * 1024 * 1024
@@ -221,9 +233,7 @@ class VMStorageOCI: PrunableStorage {
return .throw return .throw
} }
try move(digestName, from: tmpVMDir) try move(digestName, from: tmpVMDir)
transaction.finish()
}, onCancel: { }, onCancel: {
transaction.finish(status: SentrySpanStatus.cancelled)
try? FileManager.default.removeItem(at: tmpVMDir.baseURL) try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
}) })
} else { } else {
@@ -0,0 +1,28 @@
import XCTest
@testable import tart
final class ControlSocketURLTests: XCTestCase {
func testControlSocketURLResolvesToAbsolutePath() throws {
let baseURL = URL(fileURLWithPath: "/Users/test/.tart/vms/myvm/")
let vmDir = VMDirectory(baseURL: baseURL)
// The .path property resolves relative URLs to absolute paths,
// which is required for stale socket cleanup in ControlSocket.run()
// since it happens before the working directory is changed.
XCTAssertEqual(
vmDir.controlSocketURL.path,
"/Users/test/.tart/vms/myvm/control.sock"
)
}
func testControlSocketURLRelativePathIsJustFilename() throws {
let baseURL = URL(fileURLWithPath: "/Users/test/.tart/vms/myvm/")
let vmDir = VMDirectory(baseURL: baseURL)
// The .relativePath is used for socket binding after cwd is changed
XCTAssertEqual(
vmDir.controlSocketURL.relativePath,
"control.sock"
)
}
}
@@ -0,0 +1,63 @@
import XCTest
@testable import tart
final class DiskImageFormatTests: XCTestCase {
func testASIFFormatSupport() throws {
// ASIF should be supported on macOS 26+
if #available(macOS 26, *) {
XCTAssertTrue(DiskImageFormat.asif.isSupported)
} else {
XCTAssertFalse(DiskImageFormat.asif.isSupported)
}
}
func testFormatFromString() throws {
XCTAssertEqual(DiskImageFormat(rawValue: "raw"), .raw)
XCTAssertEqual(DiskImageFormat(rawValue: "asif"), .asif)
XCTAssertNil(DiskImageFormat(rawValue: "invalid"))
}
func testCaseInsensitivity() throws {
XCTAssertEqual(DiskImageFormat(argument: "ASIF"), .asif) // case insensitive
XCTAssertEqual(DiskImageFormat(argument: "Raw"), .raw) // case insensitive
}
func testAllValueStrings() throws {
let allValues = DiskImageFormat.allValueStrings
XCTAssertTrue(allValues.contains("raw"))
XCTAssertTrue(allValues.contains("asif"))
XCTAssertEqual(allValues.count, 2)
}
func testVMConfigDiskFormatSerialization() throws {
// Test that VMConfig properly serializes and deserializes disk format
let config = VMConfig(
platform: Linux(),
cpuCountMin: 2,
memorySizeMin: 1024 * 1024 * 1024,
diskFormat: .asif
)
XCTAssertEqual(config.diskFormat, .asif)
// Test JSON encoding/decoding
let encoder = JSONEncoder()
let data = try encoder.encode(config)
let decoder = JSONDecoder()
let decodedConfig = try decoder.decode(VMConfig.self, from: data)
XCTAssertEqual(decodedConfig.diskFormat, .asif)
}
func testVMConfigDefaultDiskFormat() throws {
// Test that VMConfig defaults to raw format
let config = VMConfig(
platform: Linux(),
cpuCountMin: 2,
memorySizeMin: 1024 * 1024 * 1024
)
XCTAssertEqual(config.diskFormat, .raw)
}
}
+21
View File
@@ -0,0 +1,21 @@
import XCTest
@testable import tart
final class DiskutilTests: XCTestCase {
func testDiskutilInfo() throws {
// Create a temporary directory
let tempDirURL = FileManager.default.temporaryDirectory.appendingPathComponent("tart-diskutil-tests-\(UUID().uuidString)")
try? FileManager.default.createDirectory(at: tempDirURL, withIntermediateDirectories: true)
addTeardownBlock {
try? FileManager.default.removeItem(at: tempDirURL)
}
// Create a 123 GB ASIF disk
let diskURL = tempDirURL.appendingPathComponent("disk.asif")
try Diskutil.imageCreate(diskURL: diskURL, sizeGB: 123)
// Retrieve its information and ensure that it does indeed take 123 GB
let info = try Diskutil.imageInfo(diskURL)
XCTAssertEqual(123 * 1000 * 1000 * 1000, try info.totalBytes())
}
}
+2 -23
View File
@@ -14,7 +14,7 @@ final class LayerizerTests: XCTestCase {
do { do {
registryRunner = try await RegistryRunner() registryRunner = try await RegistryRunner()
} catch { } catch {
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil) try XCTSkip("Registry is unavailable: \(error)")
} }
} }
@@ -24,30 +24,9 @@ final class LayerizerTests: XCTestCase {
registryRunner = nil registryRunner = nil
} }
func testDiskV1() async throws {
// Original disk file to be pushed to the registry
let originalDiskFileURL = try fileWithRandomData(sizeBytes: 5 * 1024 * 1024 * 1024)
addTeardownBlock {
try FileManager.default.removeItem(at: originalDiskFileURL)
}
// Disk file to be pulled from the registry
// and compared against the original disk file
let pulledDiskFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
print("pushing disk...")
let diskLayers = try await DiskV1.push(diskURL: originalDiskFileURL, registry: registry, chunkSizeMb: 0, concurrency: 4, progress: Progress())
print("pulling disk...")
try await DiskV1.pull(registry: registry, diskLayers: diskLayers, diskURL: pulledDiskFileURL, concurrency: 16, progress: Progress())
print("comparing disks...")
try XCTAssertEqual(Digest.hash(originalDiskFileURL), Digest.hash(pulledDiskFileURL))
}
func testDiskV2() async throws { func testDiskV2() async throws {
// Original disk file to be pushed to the registry // Original disk file to be pushed to the registry
let originalDiskFileURL = try fileWithRandomData(sizeBytes: 5 * 1024 * 1024 * 1024) let originalDiskFileURL = try fileWithRandomData(sizeBytes: 1 * 1024 * 1024 * 1024)
addTeardownBlock { addTeardownBlock {
try FileManager.default.removeItem(at: originalDiskFileURL) try FileManager.default.removeItem(at: originalDiskFileURL)
} }
+1 -1
View File
@@ -10,7 +10,7 @@ final class RegistryTests: XCTestCase {
do { do {
registryRunner = try await RegistryRunner() registryRunner = try await RegistryRunner()
} catch { } catch {
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil) try XCTSkip("Registry is unavailable: \(error)")
} }
} }
+2 -2
View File
@@ -13,7 +13,7 @@ class RegistryRunner {
let stdoutPipe = Pipe() let stdoutPipe = Pipe()
let proc = Process() let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/local/bin/docker") proc.executableURL = URL(fileURLWithPath: "/opt/homebrew/bin/docker")
proc.arguments = arguments proc.arguments = arguments
proc.standardOutput = stdoutPipe proc.standardOutput = stdoutPipe
try proc.run() try proc.run()
@@ -31,7 +31,7 @@ class RegistryRunner {
init() async throws { init() async throws {
// Start container // Start container
let container = try Self.dockerCmd("run", "-d", "--rm", "-p", "127.0.0.1:0:5000", "registry:2") let container = try Self.dockerCmd("run", "-d", "--rm", "-p", "127.0.0.1::5000", "registry:2")
.trimmingCharacters(in: CharacterSet.newlines) .trimmingCharacters(in: CharacterSet.newlines)
containerID = container containerID = container
+18
View File
@@ -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)
}
}
Executable
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Set shell options to enable fail-fast behavior
#
# * -e: fail the script when an error occurs or command fails
# * -u: fail the script when attempting to reference unset parameters
# * -o pipefail: by default an exit status of a pipeline is that of its
# last command, this fails the pipe early if an error in
# any of its commands occurs
#
set -euo pipefail
OUTPUT_PATH="Resources/actool"
PLIST_PATH="$OUTPUT_PATH/Info.plist"
rm -rf "${OUTPUT_PATH}"
mkdir -p "${OUTPUT_PATH}"
actool "Resources/UPW Tart.icon" \
--compile "${OUTPUT_PATH}" \
--output-format human-readable-text \
--notices \
--warnings \
--errors \
--app-icon "UPW Tart" \
--output-partial-info-plist $PLIST_PATH \
--include-all-app-icons \
--target-device mac \
--minimum-deployment-target 13.0 \
--platform macosx
+12 -28
View File
@@ -13,7 +13,7 @@ brew install go
Finally, run the following command from this (`benchmark/`) directory: Finally, run the following command from this (`benchmark/`) directory:
```shell ```shell
go run cmd/main.go fio --image ghcr.io/cirruslabs/macos-sonoma-base:latest --prepare 'sudo purge && sync' go run cmd/main.go fio --image ghcr.io/cirruslabs/macos-tahoe-base:latest --prepare 'sudo purge && sync'
``` ```
You can also enable the debugging output to diagnose issues: You can also enable the debugging output to diagnose issues:
@@ -186,41 +186,25 @@ sync test Tart (--root-disk-opts="caching=cached"
sync test Tart (--root-disk-opts="sync=none,caching=cached") 0 B/s 17 MB/s 0 IOPS 7.39 kIOPS 0s ± 0s 21.23µs ± 81.749µs 113.239µs ± 191.266µs sync test Tart (--root-disk-opts="sync=none,caching=cached") 0 B/s 17 MB/s 0 IOPS 7.39 kIOPS 0s ± 0s 21.23µs ± 81.749µs 113.239µs ± 191.266µs
``` ```
### Jan 16, 2025 ### March 23, 2025
Host: Host:
* Hardware: Mac mini (Apple M2 Pro, 8 performance and 4 efficiency cores, 32 GB RAM, `Mac14,12`) * Hardware: Mac mini (Apple M2 Pro, 8 performance and 4 efficiency cores, 32 GB RAM, `Mac14,12`)
* OS: macOS Sequoia 15.2 * OS: macOS Sequoia 15.3.2
* Xcode: 16.2
Guest: Guest:
* Hardware: [Virtualization.Framework](https://developer.apple.com/documentation/virtualization) * Hardware: [Virtualization.Framework](https://developer.apple.com/documentation/virtualization)
* OS: macOS Sonoma 14.6 * OS: macOS Sonoma 15.3.2
* Xcode: 16.2
``` ```
Name Executor Time Name Executor Time
XcodeBenchmark (d869315) local 2m15s XcodeBenchmark (d869315) local 2m19s
XcodeBenchmark (d869315) Tart 4m22s XcodeBenchmark (d869315) Tart 3m59s
XcodeBenchmark (d869315) Tart (--root-disk-opts="sync=none") 4m21s XcodeBenchmark (d869315) Tart (--root-disk-opts="sync=none") 3m48s
XcodeBenchmark (d869315) Tart (--root-disk-opts="caching=cached") 4m15s XcodeBenchmark (d869315) Tart (--root-disk-opts="caching=cached") 3m35s
XcodeBenchmark (d869315) Tart (--root-disk-opts="sync=none,caching=cached") 4m16s XcodeBenchmark (d869315) Tart (--root-disk-opts="sync=none,caching=cached") 3m14s
```
```
Name Executor Time
XcodeBenchmark (d869315) local 2m7s
XcodeBenchmark (d869315) Tart 4m37s
XcodeBenchmark (d869315) Tart (--root-disk-opts="sync=none") 4m35s
XcodeBenchmark (d869315) Tart (--root-disk-opts="caching=cached") 4m19s
XcodeBenchmark (d869315) Tart (--root-disk-opts="sync=none,caching=cached") 4m16s
```
```
Name Executor Time
XcodeBenchmark (d869315) local 2m6s
XcodeBenchmark (d869315) Tart 4m24s
XcodeBenchmark (d869315) Tart (--root-disk-opts="sync=none") 4m22s
XcodeBenchmark (d869315) Tart (--root-disk-opts="caching=cached") 4m18s
XcodeBenchmark (d869315) Tart (--root-disk-opts="sync=none,caching=cached") 4m17s
``` ```
+8 -3
View File
@@ -1,21 +1,25 @@
module github.com/cirruslabs/tart/benchmark module github.com/cirruslabs/tart/benchmark
go 1.22.1 go 1.23.0
toolchain go1.24.1
require ( require (
github.com/avast/retry-go/v4 v4.5.1 github.com/avast/retry-go/v4 v4.5.1
github.com/dustin/go-humanize v1.0.1 github.com/dustin/go-humanize v1.0.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gosuri/uitable v0.0.4 github.com/gosuri/uitable v0.0.4
github.com/shirou/gopsutil v3.21.11+incompatible
github.com/spf13/cobra v1.8.0 github.com/spf13/cobra v1.8.0
github.com/stretchr/testify v1.9.0 github.com/stretchr/testify v1.9.0
go.uber.org/zap v1.27.0 go.uber.org/zap v1.27.0
golang.org/x/crypto v0.21.0 golang.org/x/crypto v0.35.0
) )
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fatih/color v1.16.0 // indirect github.com/fatih/color v1.16.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
@@ -23,7 +27,8 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sys v0.18.0 // indirect golang.org/x/sys v0.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+13 -6
View File
@@ -7,6 +7,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY= github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY=
@@ -26,26 +28,31 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
@@ -8,6 +8,6 @@ type Benchmark struct {
var benchmarks = []Benchmark{ var benchmarks = []Benchmark{
{ {
Name: "XcodeBenchmark (d869315)", Name: "XcodeBenchmark (d869315)",
Command: "git clone https://github.com/devMEremenko/XcodeBenchmark.git && cd XcodeBenchmark && git reset --hard d86931529ada1df2a1c6646dd85958c360954065 && sh benchmark.sh", Command: "git clone https://github.com/devMEremenko/XcodeBenchmark.git && cd XcodeBenchmark && git reset --hard d86931529ada1df2a1c6646dd85958c360954065 && xcrun simctl list && sh benchmark.sh",
}, },
} }
+1 -1
View File
@@ -23,7 +23,7 @@ func NewCommand() *cobra.Command {
} }
cmd.Flags().BoolVar(&debug, "debug", false, "enable debug logging") cmd.Flags().BoolVar(&debug, "debug", false, "enable debug logging")
cmd.Flags().StringVar(&image, "image", "ghcr.io/cirruslabs/macos-sonoma-xcode:latest", "image to use for testing") cmd.Flags().StringVar(&image, "image", "ghcr.io/cirruslabs/macos-tahoe-xcode:latest", "image to use for testing")
cmd.Flags().StringVar(&prepare, "prepare", "", "command to run before running each benchmark") cmd.Flags().StringVar(&prepare, "prepare", "", "command to run before running each benchmark")
return cmd return cmd
+20
View File
@@ -7,11 +7,14 @@ import (
"fmt" "fmt"
"github.com/avast/retry-go/v4" "github.com/avast/retry-go/v4"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/shirou/gopsutil/mem"
"go.uber.org/zap" "go.uber.org/zap"
"go.uber.org/zap/zapio" "go.uber.org/zap/zapio"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
"io" "io"
"net" "net"
"runtime"
"strconv"
"strings" "strings"
"time" "time"
) )
@@ -37,6 +40,23 @@ func New(ctx context.Context, image string, runArgsExtra []string, logger *zap.L
return nil, err return nil, err
} }
vmStat, err := mem.VirtualMemory()
if err != nil {
return nil, err
}
cpus := strconv.Itoa(runtime.NumCPU())
memory := strconv.FormatUint(vmStat.Total/1024/1024, 10)
logger.Info("Setting resources", zap.String("cpus", cpus), zap.String("memory", memory))
setResourcesArguments := []string{
"set", tart.vmName,
"--cpu", cpus,
"--memory", memory,
}
if err := Cmd(ctx, tart.logger, setResourcesArguments...); err != nil {
return nil, err
}
vmRunCtx, vmRunCancel := context.WithCancel(ctx) vmRunCtx, vmRunCancel := context.WithCancel(ctx)
tart.vmRunCancel = vmRunCancel tart.vmRunCancel = vmRunCancel
+3 -1
View File
@@ -9,4 +9,6 @@
"MD033": false # Inline HTML "MD033": false # Inline HTML
"MD041": false # First line in file should be a top level heading "MD041": false # First line in file should be a top level heading
"MD045": false # OK not to have a description for an image "MD045": false # OK not to have a description for an image
"MD046": false # Code block style [Expected: fenced; Actual: indented] "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
Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

@@ -60,10 +60,14 @@ 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 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.** 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), !!! note "Pricing update"
which costs \$1000 per month. Upon reaching a limit of 500 CPU cores, a [Platinum Tier License](/licensing#license-tiers) This post announced Tart licensing in February 2023 and originally listed monthly prices.
(\$3000 per month) will be required, and for organizations that exceed 3000 CPU cores, a custom [Diamond Tier License](/licensing#license-tiers) Pricing has since changed to yearly billing. See [Licensing and Support](../../licensing.md#license-tiers) for the latest terms.
(\$1 per core per month) will be necessary. **All paid license tiers will include priority feature development and SLAs on support with urgent issues.**
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 \$12,000 per year. Upon reaching a limit of 500 CPU cores, a [Platinum Tier License](../../licensing.md#license-tiers)
(\$36,000 per year) will be required, and for organizations that exceed 3000 CPU cores, a custom [Diamond Tier License](../../licensing.md#license-tiers)
(\$12 per core per year) will be necessary. **All paid license tiers will include priority feature development and SLAs on support with urgent issues.**
## Have we considered alternatives? ## Have we considered alternatives?
+1 -1
View File
@@ -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) 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. 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 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)! [reach out](https://github.com/cirruslabs/orchard/discussions/landing)!
@@ -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: 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. All you need is to use a WebSocket client when accessing this endpoint to make it work.
+1 -1
View File
@@ -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 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: 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). -[Cluster Management via Orchard](2023-04-25-orchard-ga.md).
-Numerous compatibility improvements for all kinds of OCI-registries. -Numerous compatibility improvements for all kinds of OCI-registries.
-Sonoma Support (see details [below](#macos-sonoma-updates)). -Sonoma Support (see details [below](#macos-sonoma-updates)).
+1 -1
View File
@@ -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 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! 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. This clever synergy between Apple Hardware and Nitro System allows seamless integration with VPC networking and booting macOS from an EBS volume.
@@ -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 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**. 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: 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 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**. 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 ## Conclusion
@@ -0,0 +1,72 @@
---
draft: false
date: 2025-06-01
search:
exclude: true
authors:
- edigaryev
categories:
- announcement
---
# Bridging the gaps with the Tart Guest Agent
We're introducing a new improvement for the Tart usability experience: a [Tart Guest Agent](https://github.com/cirruslabs/tart-guest-agent).
This agent provides automatic disk resizing, seamless clipboard sharing for macOS guests (a [long-awaited](https://github.com/cirruslabs/tart/issues/14) feature), and the ability to run commands, without SSH and networking, using the new `tart exec` command.
As of recently, we include this agent in all non-vanilla Cirrus Labs images, so you likely won't need to do anything to benefit from these usability improvements.
Read on to learn why we chose to implement the agent from scratch in Golang, and which features we plan to add next.
<!-- more -->
## Existing solutions
Tart uses the Virtualization.Framework, and the latter implemented a SPICE client some time ago, however, one piece was missing: the agent that runs inside the guest.
The original [SPICE `vdagent` implementation](https://gitlab.freedesktop.org/spice/linux/vd_agent) only supports Linux. While [a fork](https://github.com/utmapp/vd_agent) from the UTM project adds macOS support, the long-term viability of maintaining this fork without upstreaming changes is uncertain.
Moreover, if we were to add some extra functionality (as we did), there would be more than one agent binary to ship and install, which complicates maintenance and makes it harder to explain to users why we need a bunch of agent binaries.
In the end, we decided to go with our own solution, one that would easily accomodate future ideas.
## Rolling our own agent
After carefully inspecting the [`vdagent` protocol](https://www.spice-space.org/agent-protocol.html) we've realized that the clipboard sharing is actually a small subset of the whole protocol, making it relatively simple to implement.
Thanks to Golang, we were able to implement the protocol much faster than we could have with a lower-level language like C (with all due respect), which requires manual memory management and complex event loops.
As for the command execution via `tart exec`, we've decided to go with gRPC with a rather simple protocol:
![An visualization of gRPC protocol used by the Tart Guest Agent](../images/tart-guest-agent-grpc-protocol.png)
For each `tart exec` invocation a new gRPC `Exec` bidirectional stream is established with the agent running inside a VM. After the gRPC stream is established, `tart exec` sends a command to execute to the guest and streams the I/O. Once the command terminates, `tart exec` collects the process exit code and quits with exactly that exit code.
Using gRPC simplifies `tart exec` implementation because of code generation and forms a nice bridge between the host and the guest which allows us to easily expand the protocol later down the road when we decide to introduce new features.
Thanks to [gRPC Swift](https://github.com/grpc/grpc-swift), which is built on top of [SwiftNIO](https://github.com/apple/swift-nio), we get [`async/await`](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/) support for free, further simplifying the `tart exec` logic.
As for the Tart Guest Agent, the final result is a Golang binary that [can be customized](https://github.com/cirruslabs/tart-guest-agent?tab=readme-ov-file#guest-agent-for-tart-vms) depending on the execution context:
* launchd global daemon — runs as a privileged user (`root`), has no clipboard access
* `--resize-disk` — resizes the disk when there's a free space at the end of a disk (assuming that one previously ran `tart set --disk-size`)
* launchd global agent — runs as a normal user (`admin`), has clipboard access
* `--run-vdagent` — clipboard sharing
* `--run-rpc` — `tart exec` and new functionality in the future
We’ve also introduced `--run-daemon` (which implies `--resize-disk`) and `--run-agent` (which implies both `--run-vdagent` and `--run-rpc`) to help run the most appropriate functionality based on the given context.
## Future plans
First, we'd like to thank our paid clients, without whom this feature wouldn't have been possible.
[Become one now](../../licensing.md) and enjoy higher allowances for Tart VMs and Orchard workers—while helping ensure that our roadmap aligns with your company's needs.
In the near future we plan to implement:
* Linux support — to provide seamless experience for Linux guests too
* a new `tart ip` resolver — to provide a more robust IP retrieval facility for Linux guests, which often struggle to populate the host's ARP table with their network activity
* `tart cp` command — to copy files from/to guest VMs
Stay tuned, and feel free to send us feedback on [GitHub](https://github.com/cirruslabs/tart) and [Twitter](https://x.com/cirrus_labs)!
@@ -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.
<!-- more -->
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 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.
!!! 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)
+126 -6
View File
@@ -5,6 +5,42 @@ title: Frequently Asked Questions
description: Advanced configuration and troubleshooting tips for advanced configurations. 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 ## 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: 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:
@@ -23,7 +59,7 @@ Remote images are pulled into `~/.tart/cache/OCIs/`.
## Nested virtualization support? ## Nested virtualization support?
Tart is limited by functionality of Apple's `Virtualization.Framework`. At the moment `Virtualization.Framework` Tart is limited by functionality of Apple's `Virtualization.Framework`. At the moment `Virtualization.Framework`
supports nested virtualization only on M3 or M4 chips running macOS 15 (Sequoia). By default, it is disabled, but can be enabled by passing the `--nested` flag to `tart run`. supports nested virtualization only on M3 or M4 chips running macOS 15 (Sequoia) or later and [only for Linux VMs](https://github.com/cirruslabs/tart/issues/1231#issuecomment-4410915463). By default, it is disabled, but can be enabled by passing the `--nested` flag to `tart run`.
## Connecting to a service running on host ## Connecting to a service running on host
@@ -34,13 +70,28 @@ Then from within a virtual machine you can access the service using the router's
or by running the following command in the Terminal: or by running the following command in the Terminal:
```shell ```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 Note: that accessing host is only possible with the default NAT network. If you are running your virtual machines with
[Softnet](https://github.com/cirruslabs/softnet) (via `tart run --net-softnet <VM NAME>)`, then the network isolation [Softnet](https://github.com/cirruslabs/softnet) (via `tart run --net-softnet <VM NAME>)`, then the network isolation
is stricter and it's not possible to access the host. is stricter and it's not possible to access the host.
## Avoiding the "Local Network" permission pop-up
Starting from macOS 15 (Sequoia), a GUI "Local Network" permission pop-up might appear when Tart is used by another tool that needs to connect to a VM over a private IPv4 network, for example [Packer](https://developer.hashicorp.com/packer/integrations/cirruslabs/tart/latest/components/builder/tart).
This is not caused by Tart itself, but by the host-side process that needs network access into the guest.
If you need a non-interactive workaround, you can configure the [local network privacy preferences](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy#macOS-considerations) on the host so that all [RFC 1918](https://datatracker.ietf.org/doc/html/rfc1918#section-3) address ranges that Tart VMs commonly use are excluded from the prompt:
```shell
sudo defaults write com.apple.network.local-network AllowedEthernetLocalNetworkAddresses -array "10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16"
sudo defaults write com.apple.network.local-network AllowedWiFiLocalNetworkAddresses -array "10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16"
```
After applying these settings, reboot the host.
## Changing the default NAT subnet ## Changing the default NAT subnet
To change the default network to `192.168.77.1`: To change the default network to `192.168.77.1`:
@@ -143,14 +194,14 @@ 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: To unlock the Keychain in an SSH session, run the following command, which will ask for your user's password:
```shell ```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](quick-start.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 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). or features supported. If there is some feature missing please don't hesitate to [create a feature request](https://github.com/cirruslabs/tart/issues).
@@ -166,6 +217,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. 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: To disable this functionality, set the `TART_NO_AUTO_PRUNE` environment variable either globally:
```shell ```shell
@@ -177,3 +230,70 @@ export TART_NO_AUTO_PRUNE=
```shell ```shell
TART_NO_AUTO_PRUNE= tart pull ... TART_NO_AUTO_PRUNE= tart pull ...
``` ```
## Disk resizing
Disk resizing works on most cloud-ready Linux distributions out-of-the box (e.g. Ubuntu Cloud Images have the `cloud-initramfs-growroot` package installed that runs on boot) and on the rest of the distributions by running the `growpart` or `resize2fs` commands.
For macOS, however, things are a bit more complicated, and you generally have two options: automated and manual resizing.
For the automated option, you can use [Packer](https://www.packer.io/) with the [Packer builder for Tart VMs](https://developer.hashicorp.com/packer/integrations/cirruslabs/tart/latest/components/builder/tart). The latter has two has configuration directives related to the disk resizing behavior:
* [`disk_size_gb`](https://developer.hashicorp.com/packer/integrations/cirruslabs/tart/latest/components/builder/tart#configuration-reference) — controls the target disk size in gigabytes
* [`recovery_partition`](https://developer.hashicorp.com/packer/integrations/cirruslabs/tart/latest/components/builder/tart#configuration-reference) — controls what to do with the recovery partition when resizing the disk
* you can either keep, delete or relocate it to the end of the disk
For the manual approach, you have to remove the recovery partition first, repair the disk and the resize the APFS container.
To do this, first we'll need to identify the primary disk and the APFS containers by running the command below from within a VM:
```shell
diskutil list physical
```
For example, the output might look like this:
```plain
/dev/disk0 (internal, physical):
#: TYPE NAME SIZE IDENTIFIER
0: GUID_partition_scheme *100.0 GB disk0
1: Apple_APFS_ISC Container disk1 524.3 MB disk0s1
2: Apple_APFS Container disk3 44.1 GB disk0s2
3: Apple_APFS_Recovery Container disk2 5.4 GB disk0s3
(free space) 50.0 GB -
```
In the output, you'll normally see:
* a single physical disk (`disk0`)
* APFS container with the system partition which we're going to resize (`disk0s2`)
* APFS container with the recovery partition which we're going to delete (`disk0s3`)
* `(free space)` which we'll put to use
To proceed, boot the VM in recovery mode using `tart run --recovery` and choose the "Options" item:
![](assets/images/faq/tart-run-recovery-options.png){width="640" .center}
When the recovery OS boots, open the Terminal app:
![](assets/images/faq/tart-run-recovery-terminal.png){width="720" .center}
In Terminal app, invoke the command below to remove the recovery partition:
```shell
diskutil eraseVolume free free disk0s3
```
Now, repair the disk:
```shell
yes | diskutil repairDisk disk0
```
Finally, resize the system APFS container to take all the remaining space:
```shell
diskutil apfs resizeContainer disk0s2 0
```
Now, you can shut down and `tart run` as you'd normally do.
+3 -3
View File
@@ -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): 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 ## Configuration
@@ -18,9 +18,9 @@ steps:
- command: uname -a - command: uname -a
plugins: plugins:
- cirruslabs/tart#main: - cirruslabs/tart#main:
image: ghcr.io/cirruslabs/macos-sonoma-base:latest image: ghcr.io/cirruslabs/macos-tahoe-base:latest
``` ```
This will run `uname -r` in a macOS Tart VM cloned from `ghcr.io/cirruslabs/macos-sonoma-base:latest`. This will run `uname -r` in a macOS Tart VM cloned from `ghcr.io/cirruslabs/macos-tahoe-base:latest`.
See plugin's [Configuration section](https://github.com/cirruslabs/tart-buildkite-plugin#configuration) for the full list of available options. See plugin's [Configuration section](https://github.com/cirruslabs/tart-buildkite-plugin#configuration) for the full list of available options.
+3 -3
View File
@@ -18,7 +18,7 @@ task:
name: hello name: hello
macos_instance: macos_instance:
# can be a remote or a local virtual machine # can be a remote or a local virtual machine
image: ghcr.io/cirruslabs/macos-sonoma-base:latest image: ghcr.io/cirruslabs/macos-tahoe-base:latest
hello_script: hello_script:
- echo "Hello from within a Tart VM!" - echo "Hello from within a Tart VM!"
- echo "Here is my CPU info:" - echo "Here is my CPU info:"
@@ -33,7 +33,7 @@ brew install cirruslabs/cli/cirrus
cirrus run 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` [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. config from above will just work in Cirrus CI and your tasks will be executed inside Tart VMs in our cloud.
@@ -50,7 +50,7 @@ exposes it via [`artifacts` instruction](https://cirrus-ci.org/guide/writing-tas
task: task:
name: Build name: Build
macos_instance: macos_instance:
image: ghcr.io/cirruslabs/macos-sonoma-xcode:latest image: ghcr.io/cirruslabs/macos-tahoe-xcode:latest
build_script: swift build --product tart build_script: swift build --product tart
binary_artifacts: binary_artifacts:
path: .build/debug/tart path: .build/debug/tart
+1 -1
View File
@@ -42,7 +42,7 @@ Now you can use Tart Images in your `.gitlab-ci.yml`:
```yaml ```yaml
# You can use any remote Tart Image. # You can use any remote Tart Image.
# Tart Executor will pull it from the registry and use it for creating ephemeral VMs. # Tart Executor will pull it from the registry and use it for creating ephemeral VMs.
image: ghcr.io/cirruslabs/macos-sonoma-base:latest image: ghcr.io/cirruslabs/macos-tahoe-base:latest
test: test:
tags: tags:
+41
View File
@@ -0,0 +1,41 @@
---
title: Automating VM image building with Packer
description: Use Packer to build custom VM images, configure VMs and work with remote OCI registries.
---
Please refer to [Tart Packer Plugin repository](https://github.com/cirruslabs/packer-plugin-tart) for setup instructions.
Here is an example of a template to build a local image based of a remote image:
```hcl
packer {
required_plugins {
tart = {
version = ">= 0.5.3"
source = "github.com/cirruslabs/tart"
}
}
}
source "tart-cli" "tart" {
vm_base_name = "ghcr.io/cirruslabs/macos-tahoe-base:latest"
vm_name = "my-custom-tahoe"
cpu_count = 4
memory_gb = 8
disk_size_gb = 70
ssh_password = "admin"
ssh_timeout = "120s"
ssh_username = "admin"
}
build {
sources = ["source.tart-cli.tart"]
provisioner "shell" {
inline = ["echo 'Disabling spotlight indexing...'", "sudo mdutil -a -i off"]
}
# more provisioners
}
```
Here is a [repository with Packer templates](https://github.com/cirruslabs/macos-image-templates) used to build [all the images managed by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
-143
View File
@@ -1,143 +0,0 @@
---
title: Managing Virtual Machine
description: Use Packer to build custom VM images, configure VMs and work with remote OCI registries.
---
# Managing Virtual Machine
## Creating from scratch
Tart supports macOS and Linux virtual machines. All commands like `run` and `pull` work the same way regardless of the underlying OS a particular VM image has.
The only difference is how such VM images are created. Please check sections below for [macOS](#creating-a-macos-vm-image-from-scratch) and [Linux](#creating-a-linux-vm-image-from-scratch) instructions.
### Creating a macOS VM image from scratch
Tart can create VMs from `*.ipsw` files. You can download a specific `*.ipsw` file [here](https://ipsw.me/) or you can
use `latest` instead of a path to `*.ipsw` to download the latest available version:
```bash
tart create --from-ipsw=latest sonoma-vanilla
tart run sonoma-vanilla
```
After the initial booting of the VM, you'll need to manually go through the macOS installation process. As a convention we recommend creating an `admin` user with an `admin` password. After the regular installation please do some additional modifications in the VM:
1. Enable Auto-Login. Users & Groups -> Login Options -> Automatic login -> admin.
2. Allow SSH. Sharing -> Remote Login
3. Disable Lock Screen. Preferences -> Lock Screen -> disable "Require Password" after 5.
4. Disable Screen Saver.
5. Run `sudo visudo` in Terminal, find `%admin ALL=(ALL) ALL` add `admin ALL=(ALL) NOPASSWD: ALL` to allow sudo without a password.
### Creating a Linux VM image from scratch
Linux VMs are supported on hosts running macOS 13.0 (Ventura) or newer.
```bash
# Create a bare VM
tart create --linux ubuntu
# Install Ubuntu
tart run --disk focal-desktop-arm64.iso ubuntu
# Run VM
tart run ubuntu
```
After the initial setup please make sure your VM can be SSH-ed into by running the following commands inside your VM:
```bash
sudo apt update
sudo apt install -y openssh-server
sudo ufw allow ssh
```
## Configuring a VM
By default, a Tart VM uses 2 CPUs and 4 GB of memory with a `1024x768` display. This can be changed after VM creation with `tart set` command.
Please refer to `tart set --help` for additional details.
## Building with Packer
Please refer to [Tart Packer Plugin repository](https://github.com/cirruslabs/packer-plugin-tart) for setup instructions.
Here is an example of a template to build a local image based of a remote image:
```hcl
packer {
required_plugins {
tart = {
version = ">= 0.5.3"
source = "github.com/cirruslabs/tart"
}
}
}
source "tart-cli" "tart" {
vm_base_name = "ghcr.io/cirruslabs/macos-sonoma-base:latest"
vm_name = "my-custom-sonoma"
cpu_count = 4
memory_gb = 8
disk_size_gb = 70
ssh_password = "admin"
ssh_timeout = "120s"
ssh_username = "admin"
}
build {
sources = ["source.tart-cli.tart"]
provisioner "shell" {
inline = ["echo 'Disabling spotlight indexing...'", "sudo mdutil -a -i off"]
}
# more provisioners
}
```
Here is a [repository with Packer templates](https://github.com/cirruslabs/macos-image-templates) used to build [all the images managed by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
## Working with a Remote OCI Container Registry
Tart supports interacting with Open Container Initiative (OCI) registries, but only runs images created and pushed by Tart. This means images created for container engines, like Docker, can't be pulled. Instead, create a custom image as documented above.
For example, let's say you want to push/pull images to an OCI registry hosted at `https://acme.io/`.
### Registry Authorization
First, you need to login to `acme.io` with the `tart login` command:
```bash
tart login acme.io
```
If you login to your registry with OAuth, you may need to create an access token to use as the password.
Credentials are securely stored in Keychain.
In addition, Tart supports [Docker credential helpers](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers)
if defined in `~/.docker/config.json`.
Finally, `TART_REGISTRY_USERNAME` and `TART_REGISTRY_PASSWORD` environment variables allow to override authorization
for all registries which might useful for integrating with your CI's secret management.
### Pushing a Local Image
Once credentials are saved for `acme.io`, run the following command to push a local images remotely with two tags:
```bash
tart push my-local-vm-name acme.io/remoteorg/name:latest acme.io/remoteorg/name:v1.0.0
```
### Pulling a Remote Image
You can either pull an image:
```bash
tart pull acme.io/remoteorg/name:latest
```
or create a VM from a remote image:
```bash
tart clone acme.io/remoteorg/name:latest my-local-vm-name
```
If the specified image is not already present, this invocation calls the `tart pull` implicitly before cloning.
+1 -1
View File
@@ -14,7 +14,7 @@ This page covers Terms of Service only for Cirrus Runners and Tart Documentation
Cirrus Labs Inc ("Cirrus Labs") operates the [Cirrus Runners service](https://cirrus-runners.app/) which we hope you use. Cirrus Labs Inc ("Cirrus Labs") operates the [Cirrus Runners service](https://cirrus-runners.app/) which we hope you use.
If you use it, please use it responsibly. If you don't, we'll have to terminate your subscription. If you use it, please use it responsibly. If you don't, we'll have to terminate your subscription.
For paid plans, you'll be charged on a monthly basis. You can cancel anytime, but there are no refunds. For paid plans, you'll be charged on a yearly basis. You can cancel anytime, but there are no refunds.
The Terms of Service and our prices can change at any time unless specified in your agreement. We'll warn you 30 days in advance of any price changes. The Terms of Service and our prices can change at any time unless specified in your agreement. We'll warn you 30 days in advance of any price changes.
We'll try to warn you about major changes to the Terms of Service, but we make no guarantees. We'll try to warn you about major changes to the Terms of Service, but we make no guarantees.

Some files were not shown because too many files have changed in this diff Show More