Compare commits

...
154 Commits
Author SHA1 Message Date
Tor Arne Vestbø 160b7cd692 Build Tart on macOS 26 with Xcode 27 (#1294)
So that we can take advantage of the new provisioning
options (VZMacGuestProvisioningOptions) for macOS 27.
2026-08-04 15:52:45 +01:00
Fedor Kororkov cbc160a592 Pass Softnet policy control FD through Tart (#1287)
* Pass Softnet policy control FD through Tart

* Update Softnet control test for policy set
2026-07-21 22:23:08 +01:00
Fedor Kororkov b9ed1a98f0 Fix Tart release signing (#1286) 2026-07-21 10:12:17 -04:00
Fedor Kororkov 057646cf89 Fix Tart release code signing (#1284) 2026-07-17 14:47:15 -04:00
Nikolai TillmannandClaude Fable 5 512c1c3630 Fix busy loop in tart exec -i after piped stdin reaches EOF (#1281)
Unregister the stdin readabilityHandler when availableData returns empty:
a closed pipe fd stays permanently readable, so Foundation re-invokes the
handler in a tight loop (fstat + zero-byte read) at 100% of one core for
the rest of the command's lifetime.

Fixes #1280

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 08:56:27 -04:00
Fedor Kororkov 9e6e59b379 [codex] Publish Tart to openai/homebrew-tools (#1277)
* Publish Tart to openai/homebrew-tools

* Write Tart formula under Formula directory

* Use macOS 26 runners

* docs: install Tart tools from OpenAI tap

* Add required GitHub Actions test check

* Fix hosted tests and notarization credentials
2026-07-16 21:32:18 -04:00
Greg Hurrell 32d084e9ed fix(homebrew): wrap macOS version dependency in on_macos block (#1264)
GoReleaser's Homebrew template always emits a bare `depends_on :macos`
for macOS-only formulae. Combining that with the `depends_on :macos =>
:ventura` line injected via custom_block triggers a Homebrew deprecation
warning on `brew upgrade`:

  Warning: Calling `depends_on :macos` with `depends_on macos:` is deprecated! Use `depends_on :macos` with `depends_on macos:` inside an `on_macos` block instead.
  Please report this issue to the cirruslabs/homebrew-cli tap (not Homebrew/* repositories), or even better, submit a PR to fix it:
    /opt/homebrew/Library/Taps/cirruslabs/homebrew-cli/tart.rb:22

Declaring the version constraint inside an `on_macos` block is the form
Homebrew recommends and silences the warning without changing behavior
(still macOS-only, Ventura or newer).
2026-06-15 18:25:38 -04:00
Tor Arne Vestbø 0a01a4430c Fix build warnings (#1262)
* Use let for the immutable disk image storage attachment

* Don't bind the unused error when catching connection-pool failures

* Report errors thrown inside tart run's fire-and-forget tasks

We were discarding any error thrown inside these unstructured tasks,
which silently hid failures to run the control socket or to start and
stop the VM, and which the compiler now warns about.

Wrap them in an ErrorReportingTask, which spawns the task and reports
any thrown error to stderr, rather than repeating a do/catch at every
call site. An unstructured task spawned from a synchronous context (a
signal handler or SwiftUI action) has no parent to propagate the error
to, so reporting it is the best we can do.

* Avoid blocking SwiftNIO calls in async guest agent connections

The gRPC channel setup in "tart exec" and the MAC address resolver
created a dedicated event loop group and tore both it and the channel
down with the blocking syncShutdownGracefully() and wait(), which are
unavailable from async contexts (the former is an error in the Swift 6
language mode).

Factor the connection out into a withGuestAgentChannel() helper that
uses the process-wide singleton event loop group, so there is no group
to shut down, and closes the channel with the async close().get().
2026-06-09 15:29:19 -07:00
Tor Arne Vestbø d1bfda63fc Add --provisioning-opts flag to provision macOS guests on first boot (#1263)
Exposes Apple's macOS 27 guest provisioning API
(VZMacGuestProvisioningOptions) so a macOS guest can be set up
automatically on the first boot after restore.

The flag takes a comma-separated list of key=value pairs mapping 1:1 to
the API properties (fullName, username, password, logsInAutomatically,
enablesRemoteLogin). It is validated to require a macOS 27+ host and a
macOS VM.

The entire user-facing surface is gated behind
'#if arch(arm64) && compiler(>=6.4)' so the flag doesn't appear in help
on toolchains that lack the macOS 27 SDK, while the runtime
'#available(macOS 27, *)' check gates actual use against the host OS.
2026-06-09 15:18:57 -07:00
Tor Arne Vestbø 2e63759c1b Don't run the AppKit run loop nested in Swift's async main (#1260)
When built against the macOS 27 (Xcode 27, Swift 6.4) SDK, "tart run"
brings up the VM window but the guest never boots.

Swift's asynchronous main() entry point implicitly starts an executor
that owns the main thread, and as of Swift 6.4 that executor is no
longer backed by the Dispatch main queue. Running an AppKit/SwiftUI
run loop nested inside it via MainApp.main() leaves the main run loop
unable to drain Swift tasks or DispatchQueue.main, so the task that
starts the VM is never scheduled, even though the window itself
(driven directly by AppKit during launch) still appears.

We now keep Root.main() synchronous, so that a command driving a run
loop can own the main thread at the top level, exactly like a plain
SwiftUI app. With AppKit owning the loop again, MainActor tasks and
the Dispatch main queue drain as before. Such commands opt in through
a new MainThreadCommand protocol; everything else keeps running
asynchronously via a detached task and dispatchMain().

Verified that the guest boots again, and that Ctrl+C still stops the
VM gracefully.
2026-06-09 09:53:06 -07:00
Fedor Kororkov 6ada2b955d Update README.md 2026-06-05 17:07:07 -07:00
Fedor Kororkov 1ea60ef420 Update docs after OpenAI move (#1240) 2026-06-05 17:05:23 -07:00
Fedor Kororkov 5ad172e7f0 Relicense under FSL-1.1-ALv2 (#1238)
* Relicense under FSL-1.1-ALv2

* Use project lifetime in copyright notice
2026-06-05 15:37:02 -07: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
Nikolay Edigaryev a4edc6af50 Make tart set --random-serial no-nop for Linux VMs (#1027) 2025-02-11 19:16:03 +04:00
Gavinkaa 2890dda847 fixing typo (#1026) 2025-02-11 14:25:04 +00:00
Nikolay Edigaryev 2d55f3b9fa docs(FAQ): document unsupported DHCP client identifiers (#1009)
* docs(FAQ): document unsupported DHCP client identifiers

* New section "Resolving the VMs IP with bridged networking"

And a more clearer explanation of what "tart ip" does.

* Remove extraneous space in ` --resolver=arp`

* Better section name

* Add a note about Linux talkativeness

* Explain "talkativeness" a bit better
2025-01-20 19:26:08 +04:00
Fedor Korotkov d3104c71b9 [docs] update manual installation script (#1008) 2025-01-20 12:56:50 +04:00
Andrew Malchuk 3ddad55372 Fix #1004: Wrong binary path in distro (#1005) 2025-01-18 13:32:54 +04:00
Nikolay Edigaryev 72a81ca84a .goreleaser.yml: caveats stanza with DHCP fix information (#1002) 2025-01-17 17:06:02 +04:00
Nikolay Edigaryev d8945503d6 Benchmark: run XcodeBenchmark with different disk settings (#1000)
* Benchmark: run XcodeBenchmark with different disk settings

* Add Xcode benchmark results
2025-01-16 23:58:26 +04:00
Nikolay Edigaryev a0fd5435de tart run: automatically enable --net-softnet when its related opts used (#994) 2025-01-16 17:47:39 +04:00
Andrew Malchuk 4cf68fc061 Build universal binary instead of architecture dependent (#995)
* Build universal binary instead of architecture dependent

* Added universal_binaries stage to goreleaser

* Fixed paths to compiled binary in .cirrus.yml

* Revert changes in .cirrus.yml, use builtin venv module instead of virtualenv only
2025-01-16 08:22:05 -05:00
Nikolay Edigaryev b626ed415b Always use write(contentsOf:) instead of write(_:) (#997) 2025-01-15 00:26:30 +04:00
Nikolay Edigaryev dd7bace92d tart run: clarify --net-softnet-expose limitations w.r.t. PF rdr rules (#996) 2025-01-14 21:52:36 +04:00
Nikolay Edigaryev 94376ca355 tart run: introduce --net-softnet-expose (#990)
* tart run: introduce --net-softnet-expose

* --net-softnet-expose: add discussion

* --net-softnet-expose: add a note about Softnet restrictions

...and how to disable them.

* LAN → local network

* Better clarify what --net-softnet does

And how --net-softnet-allow can change that behavior.
2025-01-10 05:36:51 +04:00
Fedor Korotkov 60a481857f FAQ to help with troubleshooting (#988) 2025-01-03 14:05:50 -05:00
Nikolay Edigaryev 876271dceb docs: a firewall rule needs to be created when deploying Orchard to GCE (#983) 2024-12-24 16:39:26 +04:00
Frederic BOLTZandNikolay Edigaryev 6dd43abf03 Update FAQ.md (#979)
* Update FAQ.md

* Update docs/faq.md

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

---------

Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>
2024-12-21 18:44:36 -10:00
Nikolay Edigaryev 04c6df2efb Registry: limit the text output on unexpected status code (#981)
* Registry: limit the text output on unexpected status code

* pullBlob(): limit channel read-out on error to 4 KiB

* No need to always read channel until end

This was introduced in https://github.com/cirruslabs/tart/pull/284
because we were blocking in "urlSession(_ session: URLSession, dataTask:
URLSessionDataTask, didReceive data: Data)", which we don't do anymore.

* Fetcher.fetch(): remove "progress" argument as we don't need it anymore
2024-12-20 11:55:08 +04:00
Nikolay Edigaryev 5a8b48a392 Assorted documentation improvements (#982) 2024-12-19 19:10:24 -10:00
Nikolay Edigaryev b96ea087f5 tart pull: re-try disk layer downloads by specifying "Range" header (#980) 2024-12-19 21:21:33 +04:00
Nikolay Edigaryev eaec015edf Fetcher: re-use URLSession (#976)
Otherwise we start to periodically get RST's from GitHub, possibly
because of too many connection opens, which has an effect of cancelling
previously received bytes.

These RST's can be observed in tcpdump/Wireshark or Console, emitted
from the libusrtcp.dylib library, com.apple.network subsystem, for the
Tart process:

>tcp_input [C59.1.1.1:3] flags=[R] seq=1805021659, ack=0, win=0 state=CLOSED rcv_nxt=1805021659, snd_una=1752355607

You can also observe the "Received Bytes" in "Activity Monitor" for
the Tart process while pulling ghcr.io/cirruslabs/macos-runner:sequoia,
and this value will periodically decrease.
2024-12-17 23:41:24 +04:00
Nikolay Edigaryev e27da23f4c Fetcher: avoid response deadlock (#975) 2024-12-17 01:19:06 +04:00
Nikolay Edigaryev e6a30b07e3 clone: actually reclaim unallocated bytes (#974) 2024-12-17 00:25:07 +04:00
Nikolay Edigaryev 2d7615bdf8 tart clone: only reclaim unallocated bytes (#973) 2024-12-13 02:41:26 +04:00
Nikolay Edigaryev 31ab4218f7 tart pull: 284% faster pulls with default concurrency setting (#970)
* DiskV2: avoid allocating zero chunk on each zeroSkippingWrite() call

* Increase hole granularity size from 64 KiB to 4 MiB

* Fetcher: never write to disk, thanks to URLSessionDataDelegate
2024-12-11 21:48:59 +04:00
Nikolay Edigaryev 32ebc5bdbc New benchmark results on AWS mac2.metal for Sonoma and Sequoia guests (#965)
* New benchmark results on AWS mac2.metal for Sonoma and Sequoia guests

* Document the volume type used for EBS
2024-12-04 16:03:33 -05:00
Fedor Korotkov c825ba4cb1 Better message if hardware model is not supported by the host (#962)
Related to https://github.com/cirruslabs/tart/discussions/961

It seems `VZMacHardwareModel
#init?(dataRepresentation: Data)` is nullable sometimes. Let's return a better message in this case.
2024-12-03 06:52:40 -05:00
Nikolay Edigaryev 2db3918930 Benchmark improvements (#960)
* Get a fresh instance of executor for each benchmark invocation

And don't pre-initialize all of the executors at once, as this
might reach the maximum number of VMs limit in case we want to
test multiple Tart executors.

* Run benchmarks on Tart with different --root-disk-opts options

* Fix TestTart

* benchmark fio: introduce --prepare command-line argument

To be able to specify --prepare='sudo purge && sync', similarly to
Hyperfine[1].

[1]: https://github.com/sharkdp/hyperfine

* Benchmark Tart with --root-disk-opts=caching=cached separately too

* Add Ars Technica recommended benchmarks

* Tart executor: log SSH session standard output and standard error

* Reduce file I/O size from 16 to 10 GB to avoid "No space left on device"

* Remove random writing tests to make space for more read/read-write tests

* Add some "randrw"-style fio benchmarks

* Show latency in benchmark results

* Add sync benchmark and show read/write/sync latency

* README.md: add new benchmark results
2024-12-03 00:26:26 +04:00
Nikolay Edigaryev 4256330f39 FAQ: document /var/db/dhcpd_leases and its removal (#957) 2024-11-21 22:02:21 +04:00
Nikolay Edigaryev 0794edf15a RegistryRunner: explicitly listen on localhost (#956) 2024-11-21 09:24:37 -05:00
Nikolay Edigaryev 8536c16bcc tart set: support --{,no-}display-auto-reconfigure (#954)
* tart set: support --{,no-}display-auto-reconfigure

* Remove extraneous spaces

* displayAutoReconfigure → displayRefit
2024-11-20 23:55:11 +04:00
Nikolay Edigaryev 589d489782 tart run: support specifying disk caching mode (#953) 2024-11-19 23:48:09 +04:00
Nikolay Edigaryev b1e88e1e51 tart run: do not remove "Edit" menu as its not present anymore (#946) 2024-11-18 09:55:55 +01:00
Nikolay Edigaryev b4de3bee83 tart pull: retry if we get URLError (#947) 2024-11-15 23:14:47 +01:00
Fedor Korotkov cd0f238a67 Allow to specify custom image in benchmarks (#941) 2024-11-08 16:41:05 +00:00
Fedor Korotkov 02f94720c5 Set application category (#940)
Was looking into performance and was wondering about Game Mode on Sonoma.

This change is unrelated. Just found they have a category for tools like Tart.
2024-11-07 21:12:30 +00:00
Nikolay Edigaryev c0443060cf tart run: set "prohibited" activation policy when --no-graphics is set (#939) 2024-11-07 15:20:09 -05:00
Fedor Korotkov 9c879b3f55 tart run --nested to enable nested virtualization when available (#938)
Only works for Linux VMs under Sequoia hosts.

Fixes #933
Fixes #701
2024-11-06 21:27:29 +04:00
Nikolay Edigaryev f7b38769a9 tart pull: open the VM directory after pulling under a lock (#936) 2024-11-05 00:01:14 +01:00
Nikolay Edigaryev 7c1ed4640f Info.plist: do not use LSBackgroundOnly (#935) 2024-11-04 19:08:37 +00:00
Nikolay Edigaryev 3fb8069edd Linux VMs: do not use NVMe storage device (#932) 2024-10-31 16:35:12 -04:00
Nikolay Edigaryev c78c89e274 utimes(2): use errno to explain the error (#931) 2024-10-31 16:33:03 -04:00
Fedor Korotkov 770220f905 Fixed plist file in version update (#927) 2024-10-29 13:01:47 +04:00
Nikolay Edigaryev 768d1f9bad PROFILING.md: document how to profile Tart using time(1) and xctrace(1) (#926) 2024-10-28 18:49:10 +04:00
Fedor Korotkov d49ed46439 Update access time on pull (#925)
To make sure we won't prune then immediately after. Useful for when scenarios similar to Cirrus CLI when we make sure that several images are up-to-date before every request for task execution.
2024-10-25 23:33:30 +04:00
Nikolay Edigaryev b52a857698 tart {clone,pull}: make deduplication opt-in (#924) 2024-10-25 17:56:38 +04:00
Nikolay Edigaryev 3bf0bb22f3 CI: populate CFBundleShortVersionString in Info.plist (#923) 2024-10-24 18:47:50 +00:00
Nikolay Edigaryev accbd0cb33 Registry: prevent double authorization when getting a new token (#922) 2024-10-23 23:51:55 +04:00
Nikolay Edigaryev c0b20932c7 Prevent pipe deadlock when spawning a Process() (#916) 2024-10-02 09:48:23 -04:00
Nikolay Edigaryev 3694af946c Document automatic pruning in FAQ (#913)
* Document automatic pruning in FAQ

* tart {pull,clone}: consistent automatic pruning documentation in --help
2024-09-30 21:56:55 +04:00
Nikolay Edigaryev dbf711a6c9 tart delete: return human-friendly error when local VM doesn't exist (#910) 2024-09-26 14:45:14 +04:00
Nikolay Edigaryev b9f24a40c1 Info.plist: set CFBundleName and CFBundleDisplayName to Tart (#909) 2024-09-24 17:24:50 +04:00
Nikolay Edigaryev 10c6ace671 Re-generate ANTLR files using ANTLR 4.13.2 (#907)
* Re-generate ANTLR files using ANTLR 4.13.2

* Package.swift: require exactly ANTLR of version 4.13.2
2024-09-20 17:50:14 +04:00
Nikolay Edigaryev b98e23956b Package.swift: bump Sentry SDK to 8.36.0 + upgrade other packages (#905)
* Package.swift: bump Sentry SDK to 8.36.0

* $ swift package update
2024-09-19 19:06:37 +00:00
Fedor Korotkov ce23f9c2a7 Completely disable audio devices in case of --no-audio (#904)
This way VM won't have empty audio device at all.

This should fix with an issue like that https://github.com/actions/runner-images/issues/9330
2024-09-17 09:40:37 +00:00
Nikolay Edigaryev 3da91e6518 tart run: provide a hint with names of other running VMs (#900)
When VM limit gets exceeded.
2024-09-09 20:45:59 +04:00
Nikolay Edigaryev 7046886713 docs(orchard): document Kubernetes and systemd service deployment (#899) 2024-09-09 16:40:17 +04:00
Nikolay EdigaryevandFedor Korotkov 3fde7d08dd Orchard documentation (#897)
* Orchard documentation

* Fix typo

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

* architecture-and-security.md: change list order

---------

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>
2024-08-28 00:09:57 +04:00
Fedor Korotkov 227301436c Revert "Drop Monterey Support (#843)" (#893)
This reverts commit 017592075f.
2024-08-14 14:57:55 -04:00
Nikolay Edigaryev 106eb5a2c8 tart push: re-try when encountering errors when pushing disk layers (#888)
* tart push: re-try when encountering errors when pushing disk layers

* Only re-try on URLError
2024-08-10 13:06:39 -04:00
Nikolay Edigaryev 10bf706653 tart push: avoid uploading blobs if they are already present (#887)
By issuing HEAD requests to the registry before doing the actual upload.
2024-08-09 17:26:20 +04:00
Fedor Korotkov ff928ad77d Optimize DiskV2 Deduplication (#878)
* Revert "Lowercase `tart.app` (#751)"

This reverts commit a9e2a19015.

* Optimize DiskV2 deduplication logic

In case we cloned `disk.img` from a local image, check if data at offset has the expected contents already.

* Hole punch only if needed

* Calculate hash only if needed

* subdataChunks optimization

* Reapply "Lowercase `tart.app` (#751)"

This reverts commit e74e9c845a.

* format

* Save at least 1GB on deduplication logic

* Build separately

* Revert "subdataChunks optimization"

This reverts commit e59382aeba.

* Another optimization

* Removed debug log

* reformat

* Revert "Hole punch only if needed"

This reverts commit 8c569fc5
2024-08-05 12:24:31 -04:00
Nikolay Edigaryev 33b5cfe2ed tart run: delay tilde (~) expansion until we're dealing with local path (#880) 2024-08-05 15:42:10 +04:00
Nikolay Edigaryev 3892cdb00d tart run: replace --sync with --root-disk-opts (#879)
* VZDiskImageSynchronizationMode's "description" field is a dead code

* Re-use the VZDiskImageSynchronizationMode extension

* tart run: replace --sync with --root-disk-opts

* VM: support root disk synchronization mode on macOS
2024-08-05 15:17:58 +04:00
Nicholas FitzRoy-Dale 5f2199ef3e Support setting root disk synchronization mode (#875)
* Support setting root disk synchronization mode

Adds a new VMConfig parameter (tart get / tart set) called 'sync' which
can be set to 'full' (default), 'fsync', or 'none', corresponding with
the values of VZDiskImageSynchronizationMode and allowing a tradeoff
between data integrity and speed.

* Remove unused import

* Fix formatting

* Make root disk sync behaviour a commandline option
2024-08-05 13:12:43 +04:00
Fedor Korotkov 3f26baa341 Update testimonials to focus on Tart (#876)
Cirrus Runners have their own testimonials and a website now. No need to mix things together.
2024-08-01 17:09:08 +04:00
Nikolay Edigaryev 06cae1296e tart run: support disabling disk synchronization for --disk (#872) 2024-07-25 20:15:07 +04:00
Nikolay EdigaryevandFedor Korotkov 1b81b12760 tart pull: try to re-use APFS blocks by cloning the base image (#864)
* tart pull: try to re-use APFS blocks by cloning the base image

* Punch a hole when a zero chunk is detected

* Properly retrieve errno when hole punching operation fails

* tart pull: do not retry on RuntimeError

* Ensure that the holes we're about to punch are FS block size-aligned

* VMDirectory: remove unused static variables

* tart pull: log if we've found an image to deduplicate against

* Do not prematurely read contents from disk

* Only consider candidates with deduplicatedBytes more than 0

* APFS reuse UX/DX improvements (#870)

* Show how much deduplication happening

Improvement to the APFS deduplication logic which checks whether a disk image file `mayShareFileContent` with some other file, and then we put a custom attribute to track the deduplication since there is no way to get this information from APFS itself.

It's not 100% accurate but given that OCI cache is immutable the actual disk usage can only be lover than that.

* Use string attribute

* Update Sources/tart/URL+Prunable.swift

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

* Added SizeOnDisk colume

---------

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

---------

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>
2024-07-25 15:33:15 +00:00
Nikolay Edigaryev 4ed73bc775 --no-audio: only disable the source and sink (#869)
To prevent crashes in the guest when playing or recording audio.
2024-07-18 15:10:44 +00:00
Nikolay Edigaryev 2dc25ce478 tart push: support --concurrency command-line argument (#868)
* tart push: support --concurrency command-line argument

* LayerizerTests: specify "concurrency" argument
2024-07-18 17:52:55 +04:00
164 changed files with 6025 additions and 1927 deletions
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
set -eu
ARCH="$1"
SCRATCH_PATH=".build/$ARCH"
OUTPUT_PATH=".build/prebuilt/$ARCH"
swift build \
--build-system swiftbuild \
--scratch-path "$SCRATCH_PATH" \
--arch "$ARCH" \
--configuration release \
--product tart
BIN_PATH=$(swift build \
--build-system swiftbuild \
--scratch-path "$SCRATCH_PATH" \
--arch "$ARCH" \
--configuration release \
--show-bin-path)
mkdir -p "$OUTPUT_PATH"
cp "$BIN_PATH/tart" "$OUTPUT_PATH/tart"
+1 -1
View File
@@ -2,7 +2,7 @@
set -e
export VERSION="${CIRRUS_TAG:-0}"
export VERSION="${VERSION:-0}"
mkdir -p .ci/pkg/
cp .build/arm64-apple-macosx/release/tart .ci/pkg/tart
+8 -2
View File
@@ -1,5 +1,11 @@
#!/bin/sh
set -e
: "${VERSION:?VERSION must be set}"
TMPFILE=$(mktemp)
envsubst < Sources/tart/CI/CI.swift > $TMPFILE
mv $TMPFILE Sources/tart/CI/CI.swift
perl -pe 's/\$\{VERSION\}/$ENV{VERSION}/g' Sources/tart/CI/CI.swift > "$TMPFILE"
mv "$TMPFILE" Sources/tart/CI/CI.swift
/usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string ${VERSION}" Resources/Info.plist
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
set -eu
APP_PATH="dist/tart_darwin_all/tart.app"
if [ "${TART_RELEASE_SNAPSHOT:-false}" = "true" ]; then
codesign \
--force \
--deep \
--sign - \
--entitlements Resources/tart-dev.entitlements \
"$APP_PATH"
else
codesign \
--force \
--verbose \
--sign "Developer ID Application: Cirrus Labs, Inc. (9M2P8L4D89)" \
--timestamp \
--options runtime \
--keychain "$RUNNER_TEMP/build.keychain" \
--entitlements Resources/tart-prod.entitlements \
"$APP_PATH"
fi
codesign --verify --strict --verbose=2 "$APP_PATH"
"$APP_PATH/Contents/MacOS/tart" --version
if [ "${TART_RELEASE_SNAPSHOT:-false}" != "true" ]; then
NOTARIZATION_ARCHIVE="$RUNNER_TEMP/tart-notarization.zip"
ditto -c -k --keepParent "$APP_PATH" "$NOTARIZATION_ARCHIVE"
xcrun notarytool submit "$NOTARIZATION_ARCHIVE" \
--keychain-profile "notarytool" \
--keychain "$RUNNER_TEMP/build.keychain" \
--wait \
--timeout 20m
xcrun stapler staple "$APP_PATH"
xcrun stapler validate "$APP_PATH"
spctl --assess --type execute --verbose=4 "$APP_PATH"
fi
+11 -93
View File
@@ -1,27 +1,29 @@
use_compute_credits: true
task:
name: Test on Sonoma
name: Test
alias: test
persistent_worker:
labels:
name: dev-mini
resources:
tart-vms: 1
build_script:
- swift build
test_script:
# Add /usr/sbin to PATH, otherwise testDiskutilInfo() fails to locate "diskutil"
- export PATH=$PATH:/usr/sbin
- swift test
integration_test_script:
# Build Tart
- swift build
- codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
- export PATH=$(pwd)/.build/arm64-apple-macosx/debug:$PATH
# Run integration tests
- cd integration-tests
- HOMEBREW_NO_AUTO_UPDATE=1 brew install virtualenv
- virtualenv venv
- python3 -m venv --symlinks venv
- source venv/bin/activate
- pip install -r requirements.txt
- pytest --verbose --junit-xml=pytest-junit.xml
- go test -v ./...
pytest_junit_result_artifacts:
path: "integration-tests/pytest-junit.xml"
format: junit
@@ -38,7 +40,7 @@ task:
name: Lint
alias: lint
macos_instance:
image: ghcr.io/cirruslabs/macos-runner:sonoma
image: ghcr.io/cirruslabs/macos-runner:tahoe
lint_script:
- swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json .
always:
@@ -55,105 +57,21 @@ task:
name: Build ($BUILD_ARCH)
alias: build
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
sign_script: codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/$BUILD_ARCH-apple-macosx/debug/tart
binary_artifacts:
path: .build/$BUILD_ARCH-apple-macosx/debug/tart
task:
only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin')
name: Release (Dry Run)
depends_on:
- lint
- build
macos_instance:
image: ghcr.io/cirruslabs/macos-runner:sonoma
env:
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
setup_script:
- cd $HOME
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
- security create-keychain -p password101 build.keychain
- security default-keychain -s build.keychain
- security unlock-keychain -p password101 build.keychain
- security import certificate.p12 -k build.keychain -P password101 -T /usr/bin/codesign -T /usr/bin/pkgbuild
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
install_script:
- brew install go goreleaser/tap/goreleaser-pro
- brew install mitchellh/gon/gon
info_script:
- security find-identity -v
- xcodebuild -version
- swift -version
goreleaser_script: goreleaser release --skip=publish --snapshot --clean
always:
dist_artifacts:
path: "dist/*"
task:
name: Release
only_if: $CIRRUS_TAG != ''
depends_on:
- lint
- test
- build
macos_instance:
image: ghcr.io/cirruslabs/macos-runner:sonoma
env:
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
SENTRY_ORG: cirrus-labs
SENTRY_PROJECT: persistent-workers
SENTRY_AUTH_TOKEN: ENCRYPTED[!9eaf2875d51b113e2f68598441ff8e6b2e53242e48fcb93633bd75a373fbe2e7caa900d837cc92f0b142b65579731644!]
setup_script:
- cd $HOME
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
- security create-keychain -p password101 build.keychain
- security default-keychain -s build.keychain
- security unlock-keychain -p password101 build.keychain
- security import certificate.p12 -k build.keychain -P password101 -T /usr/bin/codesign -T /usr/bin/pkgbuild
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
install_script:
- brew install go goreleaser/tap/goreleaser-pro getsentry/tools/sentry-cli
- brew install mitchellh/gon/gon
info_script:
- security find-identity -v
- xcodebuild -version
- swift -version
release_script: goreleaser
upload_sentry_debug_files_script:
- cd .build/arm64-apple-macosx/release/
# Generate and upload symbols
- dsymutil tart
- sentry-cli debug-files upload tart.dSYM/
- SENTRY_PROJECT=tart sentry-cli debug-files upload tart.dSYM/
# Bundle and upload sources
- sentry-cli debug-files bundle-sources tart.dSYM
- sentry-cli debug-files upload tart.src.zip
- SENTRY_PROJECT=tart sentry-cli debug-files upload tart.src.zip
create_sentry_release_script:
- export SENTRY_RELEASE="tart@$CIRRUS_TAG"
- sentry-cli releases new $SENTRY_RELEASE
- sentry-cli releases set-commits $SENTRY_RELEASE --auto
- sentry-cli releases finalize $SENTRY_RELEASE
task:
name: Deploy Documentation
only_if: $CIRRUS_BRANCH == 'main'
container:
image: ghcr.io/cirruslabs/mkdocs-material-insiders:latest
image: ghcr.io/squidfunk/mkdocs-material:latest
registry_config: ENCRYPTED[!cf1a0f25325aa75bad3ce6ebc890bc53eb0044c02efa70d8cefb83ba9766275a994b4831706c52630a0692b2fa9cfb9e!]
env:
DEPLOY_TOKEN: ENCRYPTED[!45ed45666558902ed1c2400add734ec063103bec31841847e8c8764802fca229bfa6d85c690e16ad159e047574b48793!]
deploy_script:
deploy_script:
- git config --global user.name "Cirrus CI"
- git config --global user.name "hello@cirruslabs.org"
- git remote set-url origin https://$DEPLOY_TOKEN@github.com/cirruslabs/tart/
+5
View File
@@ -4,3 +4,8 @@ root = true
indent_style = space
indent_size = 2
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: xcode-27
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: xcode-27
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- name: Build
run: swift build --build-system swiftbuild --product tart
+37
View File
@@ -0,0 +1,37 @@
name: CI
on:
merge_group:
pull_request:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
jobs:
test:
name: Test
runs-on: xcode-27
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: integration-tests/go.mod
cache-dependency-path: integration-tests/go.sum
- name: Build
run: swift build --build-system swiftbuild
- name: Run unit tests
run: |
export PATH="$PATH:/usr/sbin"
swift test --build-system swiftbuild
# The Python suite boots Tart VMs, but hosted ARM macOS runners do not support nested virtualization.
- name: Run OpenTelemetry integration tests
run: |
bin_path="$(swift build --build-system swiftbuild --show-bin-path)"
codesign --sign - --entitlements Resources/tart-dev.entitlements --force "$bin_path/tart"
cd integration-tests
PATH="$bin_path:$PATH" go test -v ./...
+111
View File
@@ -0,0 +1,111 @@
name: Release
on:
push:
tags:
- "*"
workflow_dispatch:
permissions:
contents: read
jobs:
release:
if: github.event_name == 'push' && github.repository == 'openai/tart'
name: Release
runs-on: xcode-27
environment: publish
timeout-minutes: 90
permissions:
contents: read
env:
VERSION: ${{ github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Import signing certificate
env:
AC_PASSWORD: ${{ secrets.AC_PASSWORD }}
KEYCHAIN_PASSWORD: temporary-password
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
P12_PASSWORD: password101
run: |
echo "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/certificate.p12"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$RUNNER_TEMP/build.keychain"
security set-keychain-settings -lut 21600 "$RUNNER_TEMP/build.keychain"
security default-keychain -s "$RUNNER_TEMP/build.keychain"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$RUNNER_TEMP/build.keychain"
security import "$RUNNER_TEMP/certificate.p12" \
-k "$RUNNER_TEMP/build.keychain" \
-P "$P12_PASSWORD" \
-T /usr/bin/codesign \
-T /usr/bin/pkgbuild
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s \
-k "$KEYCHAIN_PASSWORD" \
"$RUNNER_TEMP/build.keychain"
security list-keychain -d user -s "$RUNNER_TEMP/build.keychain"
xcrun notarytool store-credentials "notarytool" \
--apple-id "hello@cirruslabs.org" \
--team-id "9M2P8L4D89" \
--password "$AC_PASSWORD" \
--keychain "$RUNNER_TEMP/build.keychain"
- name: Create release app token for this repo
id: app-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
permission-contents: write
- name: Create release app token for homebrew-tools
id: tap-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
owner: openai
repositories: homebrew-tools
permission-contents: write
permission-pull-requests: write
- name: Release
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
with:
distribution: goreleaser-pro
version: "~> v2"
args: release --clean
env:
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ steps.tap-token.outputs.token }}
snapshot:
if: github.event_name == 'workflow_dispatch'
name: Release (Dry Run)
runs-on: xcode-27
timeout-minutes: 90
permissions:
contents: read
env:
TART_RELEASE_SNAPSHOT: "true"
VERSION: snapshot
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Build snapshot
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
with:
distribution: goreleaser-pro
version: "~> v2"
args: release --skip=publish --snapshot --clean
- name: Upload snapshot artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: tart-snapshot
path: dist/*
+3
View File
@@ -8,6 +8,9 @@ tart.xcodeproj/
# AppCode
.idea/
# VS Code
.vscode/
# Swift
.build/
+51 -22
View File
@@ -1,11 +1,12 @@
version: 2
project_name: tart
before:
hooks:
- .ci/set-version.sh
- swift build --arch x86_64 -c release --product tart
- swift build --arch arm64 -c release --product tart
- gon gon.hcl
- sh .ci/build-release.sh arm64
- sh .ci/build-release.sh x86_64
builds:
- id: tart
@@ -18,20 +19,33 @@ builds:
- amd64
binary: tart.app/Contents/MacOS/tart
prebuilt:
path: '.build/{{- if eq .Arch "arm64" }}arm64{{- else }}x86_64{{ end }}-apple-macosx/release/tart'
path: '.build/prebuilt/{{- if eq .Arch "arm64" }}arm64{{- else }}x86_64{{ end }}/tart'
universal_binaries:
- name_template: tart.app/Contents/MacOS/tart
replace: true
hooks:
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/
- cmd: .ci/sign-release.sh
output: true
archives:
- name_template: "{{ .ProjectName }}-{{ .Arch }}"
- name_template: "{{ .ProjectName }}"
files:
- src: Resources/embedded.provisionprofile
dst: tart.app/Contents
strip_parent: true
- src: Resources/Info.plist
dst: tart.app/Contents
strip_parent: true
- src: Resources/AppIcon.png
dst: tart.app/Contents/Resources
strip_parent: true
- src: dist/tart_darwin_all/tart.app/Contents/Info.plist
dst: tart.app/Contents/Info.plist
- src: dist/tart_darwin_all/tart.app/Contents/embedded.provisionprofile
dst: tart.app/Contents/embedded.provisionprofile
- src: dist/tart_darwin_all/tart.app/Contents/Resources/UPW Tart.icns
dst: tart.app/Contents/Resources/UPW Tart.icns
- src: dist/tart_darwin_all/tart.app/Contents/Resources/Assets.car
dst: tart.app/Contents/Resources/Assets.car
- src: dist/tart_darwin_all/tart.app/Contents/_CodeSignature/CodeResources
dst: tart.app/Contents/_CodeSignature/CodeResources
- LICENSE
release:
@@ -39,19 +53,34 @@ release:
brews:
- name: tart
directory: Formula
repository:
owner: cirruslabs
name: homebrew-cli
caveats: See the GitHub repository for more information
homepage: https://github.com/cirruslabs/tart
license: "Fair Source"
owner: openai
name: homebrew-tools
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
branch: "tart-{{ .Version }}"
pull_request:
enabled: true
caveats: |
Tart has been installed. You might want to reduce the default DHCP lease time
from 86,400 to 600 seconds to avoid DHCP shortage when running lots of VMs daily:
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.InternetSharing.default.plist bootpd -dict DHCPLeaseTimeSecs -int 600
See https://tart.run/faq/#changing-the-default-dhcp-lease-time for more details.
homepage: https://github.com/openai/tart
license: FSL-1.1-ALv2
description: Run macOS and Linux VMs on Apple Hardware
skip_upload: auto
dependencies:
- "cirruslabs/cli/softnet"
- "openai/tools/softnet"
install: |
libexec.install Dir["*"]
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: |
depends_on :macos => :ventura
on_macos do
depends_on :macos => :ventura
end
def post_install
generate_completions_from_executable(libexec/"tart.app/Contents/MacOS/tart", "--generate-completion-script")
end
+2 -1
View File
@@ -20,7 +20,7 @@ Table of Contents
```
## How to Create an Issue/Enhancement
1. Go to the [Issue page](https://github.com/cirruslabs/tart/issues) of the repository
1. Go to the [Issue page](https://github.com/openai/tart/issues) of the repository
2. Click on the "New Issue" button
3. Provide a descriptive title and detailed description of the issue or enhancement you're suggesting
4. Submit the issue
@@ -29,6 +29,7 @@ Table of Contents
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:
```bash
swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore .
```
+95 -35
View File
@@ -1,45 +1,105 @@
Fair Source License, version 0.9
# Functional Source License, Version 1.1, ALv2 Future License
Copyright (C) 2023 Cirrus Labs, Inc.
## Abbreviation
Licensor: Cirrus Labs, Inc.
FSL-1.1-ALv2
Software: Tart
## Notice
Use Limitation: 100 users. User is defined as a single core of a central processing unit (CPU) used by the product.
The Use Limitation does not apply to CPUs installed in devices used by a single individual.
Copyright 2022-2026 OpenAI
License Grant. Licensor hereby grants to each recipient of the
Software ("you") a non-exclusive, non-transferable, royalty-free and
fully-paid-up license, under all of the Licensor's copyright and
patent rights, to use, copy, distribute, prepare derivative works of,
publicly perform and display the Software, subject to the Use
Limitation and the conditions set forth below.
## Terms and Conditions
Use Limitation. The license granted above allows use by up to the
number of users per entity set forth above (the "Use Limitation"). For
determining the number of users, "you" includes all affiliates,
meaning legal entities controlling, controlled by, or under common
control with you. If you exceed the Use Limitation, your use is
subject to payment of Licensor's then-current list price for licenses.
### Licensor ("We")
Conditions. Redistribution in source code or other forms must include
a copy of this license document to be provided in a reasonable
manner. Any redistribution of the Software is only allowed subject to
this license.
The party offering the Software under these Terms and Conditions.
Trademarks. This license does not grant you any right in the
trademarks, service marks, brand names or logos of Licensor.
### The Software
DISCLAIMER. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OR
CONDITION, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. LICENSORS HEREBY DISCLAIM ALL LIABILITY, WHETHER IN
AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE.
The "Software" is each version of the software that we make available under
these Terms and Conditions, as indicated by our inclusion of these Terms and
Conditions with the Software.
Termination. If you violate the terms of this license, your rights
will terminate automatically and will not be reinstated without the
prior written consent of Licensor. Any such termination will not
affect the right of others who may have received copies of the
Software from you.
### License Grant
Subject to your compliance with this License Grant and the Patents,
Redistribution and Trademark clauses below, we hereby grant you the right to
use, copy, modify, create derivative works, publicly perform, publicly display
and redistribute the Software for any Permitted Purpose identified below.
### Permitted Purpose
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
means making the Software available to others in a commercial product or
service that:
1. substitutes for the Software;
2. substitutes for any other product or service we offer using the Software
that exists as of the date we make the Software available; or
3. offers the same or substantially similar functionality as the Software.
Permitted Purposes specifically include using the Software:
1. for your internal use and access;
2. for non-commercial education;
3. for non-commercial research; and
4. in connection with professional services that you provide to a licensee
using the Software in accordance with these Terms and Conditions.
### Patents
To the extent your use for a Permitted Purpose would necessarily infringe our
patents, the license grant above includes a license under our patents. If you
make a claim against any party that the Software infringes or contributes to
the infringement of any patent, then your patent license to the Software ends
immediately.
### Redistribution
The Terms and Conditions apply to all copies, modifications and derivatives of
the Software.
If you redistribute any copies, modifications or derivatives of the Software,
you must include a copy of or a link to these Terms and Conditions and not
remove any copyright notices provided in or with the Software.
### Disclaimer
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
### Trademarks
Except for displaying the License Details and identifying us as the origin of
the Software, you have no right under these Terms and Conditions to use our
trademarks, trade names, service marks or product names.
## Grant of Future License
We hereby irrevocably grant you an additional license to use the Software under
the Apache License, Version 2.0 that is effective on the second anniversary of
the date we make the Software available. On or after that date, you may use the
Software under the Apache License, Version 2.0, in which case the following
will apply:
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either express or implied. See the License for the
specific language governing permissions and limitations under the License.
+64
View File
@@ -0,0 +1,64 @@
# Profiling Tart
## Using `time(1)`
Perhaps, the easiest, but not the most comprehensive way to tell what's going on with Tart is to use the [`time(1)`](https://ss64.com/mac/time.html) command.
In the example below, you will run `tart pull` via `time(1)` to gather generalized CPU, I/O and memory usage metrics:
```shell
/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:** The `-l` command-line argument makes `time(1)` return much more useful information, for example, maximum memory usage.
When running the command above, you'll see the `tart pull` output first as it pulls the image, and then the `time(1)` output, which will be printed once the Tart process finishes:
```
172.17 real 10.29 user 8.36 sys
353796096 maximum resident set size
0 average shared memory size
0 average unshared data size
0 average unshared stack size
23838 page reclaims
35 page faults
0 swaps
0 block input operations
0 block output operations
8 messages sent
8 messages received
0 signals received
146 voluntary context switches
222950 involuntary context switches
39683070975 instructions retired
27562035252 cycles elapsed
170920448 peak memory footprint
```
From the output above, you can tell that `tart pull` spent nearly 90% of time off-CPU (`real` > `user` + `sys`), which means that Tart was mostly waiting for the I/O (be it a network or disk), instead of decompressing disk layers or doing other useful computations.
## Using `xctrace(1)`
[`xctrace(1)`](https://keith.github.io/xcode-man-pages/xctrace.1.html) is a `.trace` format recorder for the [Instruments](https://en.wikipedia.org/wiki/Instruments_(software)) app, which yields much more powerful insights compared to `time(1)`. For example, it can tell which Tart functions spent the most time on the CPU, thus allowing the Tart developers to further optimize these functions.
To use it, make sure that [Xcode](https://developer.apple.com/xcode/resources/) is installed. If you're installing Xcode for the first time on the machine, you'll need to launch it once and click the blue "Install" button. There's no need to choose any platforms except for the macOS.
Once done, you can create a CPU profile of `tart pull`:
```shell
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:
```
Output file saved as: Launch_[...].trace
```
To view this trace in the Instruments app, simply find this directory in Finder and double-click it. Instruments app will appear:
![](Resources/Instruments.png)
To send this trace, right-click its directory in Finder and choose "Compress [...]". This will result in a similarly named file with a `.zip` at the end, which can now be conveniently sent via email or uploaded.
+179 -35
View File
@@ -1,13 +1,31 @@
{
"originHash" : "6d48639bc0ea02002de0b4f38fe3fce0ddc9d174f2e56180c2ffcbedb7391ef8",
"originHash" : "061dfe6cdf4e6dbf32b51c5e7023c4ae69726dcafb42a35b34e5489b0338c17f",
"pins" : [
{
"identity" : "antlr4",
"kind" : "remoteSourceControl",
"location" : "https://github.com/antlr/antlr4",
"state" : {
"branch" : "dev",
"revision" : "2703a8516c0fb7fe92db6b9c40e0113f577646d2"
"revision" : "cc82115a4e7f53d71d9d905caa2c2dfa4da58899",
"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"
}
},
{
@@ -19,22 +37,49 @@
"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",
"kind" : "remoteSourceControl",
"location" : "https://github.com/groue/Semaphore",
"state" : {
"revision" : "f1c4a0acabeb591068dea6cffdd39660b86dec28",
"version" : "0.0.8"
}
},
{
"identity" : "sentry-cocoa",
"kind" : "remoteSourceControl",
"location" : "https://github.com/getsentry/sentry-cocoa",
"state" : {
"revision" : "ef4fec9dfb8dd5027b09a4a5c9362feafd118e1a",
"version" : "8.24.0"
"revision" : "2543679282aa6f6c8ecf2138acd613ed20790bc2",
"version" : "0.1.0"
}
},
{
@@ -51,17 +96,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser",
"state" : {
"revision" : "46989693916f56d1186bd59ac15124caef896560",
"version" : "1.3.1"
}
},
{
"identity" : "swift-async-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms",
"state" : {
"branch" : "main",
"revision" : "f05e450f0b909c0e80670a47516c4b9700b9e5da"
"revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3",
"version" : "1.6.1"
}
},
{
@@ -69,8 +105,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "cd142fd2f64be2100422d658e7411e39489da985",
"version" : "1.2.0"
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
"version" : "1.3.0"
}
},
{
@@ -78,8 +114,26 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "f504716c27d2e5d4144fa4794b12129301d17729",
"version" : "1.0.3"
"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"
}
},
{
@@ -87,8 +141,62 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "e97a6fcb1ab07462881ac165fdbb37f067e205d5",
"version" : "1.5.4"
"revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181",
"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"
}
},
{
@@ -100,13 +208,22 @@
"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",
"kind" : "remoteSourceControl",
"location" : "https://github.com/fumoboy007/swift-retry",
"state" : {
"revision" : "9f133487ffc2ab4539688c29efe57bb1ba31d7b0",
"version" : "0.2.3"
"revision" : "df9d7b185d2e433147ec0083a73c257e665eea0d",
"version" : "0.2.4"
}
},
{
@@ -118,6 +235,24 @@
"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",
"kind" : "remoteSourceControl",
"location" : "https://github.com/jozefizso/swift-xattr",
"state" : {
"revision" : "f8605af7b3290dbb235fb182ec6e9035d0c8c3ac",
"version" : "3.0.0"
}
},
{
"identity" : "swiftdate",
"kind" : "remoteSourceControl",
@@ -132,8 +267,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/nicklockwood/SwiftFormat",
"state" : {
"revision" : "9df3b01f477163b33d5e63c5e2e5b9f946a49c56",
"version" : "0.53.6"
"revision" : "ab6844edb79a7b88dc6320e6cee0a0db7674dac3",
"version" : "0.54.5"
}
},
{
@@ -153,6 +288,15 @@
"branch" : "master",
"revision" : "e03289289155b4e7aa565e32862f9cb42140596a"
}
},
{
"identity" : "thrift-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/undefinedlabs/Thrift-Swift",
"state" : {
"revision" : "18ff09e6b30e589ed38f90a1af23e193b8ecef8e",
"version" : "1.1.2"
}
}
],
"version" : 3
+15 -6
View File
@@ -10,36 +10,45 @@ let package = Package(
.executable(name: "tart", targets: ["tart"])
],
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/apple/swift-algorithms", from: "1.2.0"),
.package(url: "https://github.com/apple/swift-async-algorithms", branch: "main"),
.package(url: "https://github.com/malcommac/SwiftDate", from: "7.0.0"),
.package(url: "https://github.com/antlr/antlr4", branch: "dev"),
.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/nicklockwood/SwiftFormat", from: "0.53.6"),
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.24.0"),
.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/orchetect/SwiftRadix", from: "1.3.1"),
.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/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: [
.executableTarget(name: "tart", dependencies: [
.product(name: "Algorithms", package: "swift-algorithms"),
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Dynamic", package: "Dynamic"),
.product(name: "SwiftDate", package: "SwiftDate"),
.product(name: "Antlr4Static", package: "Antlr4"),
.product(name: "Atomics", package: "swift-atomics"),
.product(name: "Sentry", package: "sentry-cocoa"),
.product(name: "TextTable", package: "TextTable"),
.product(name: "Sysctl", package: "swift-sysctl"),
.product(name: "SwiftRadix", package: "SwiftRadix"),
.product(name: "Semaphore", package: "Semaphore"),
.product(name: "DMRetry", package: "swift-retry"),
.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: [
"OCI/Reference/Makefile",
"OCI/Reference/Reference.g4",
+19 -31
View File
@@ -1,4 +1,4 @@
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartSocial.png"/>
<img src="https://github.com/openai/tart/raw/main/Resources/TartSocial.png"/>
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines (VMs) on Apple Silicon.
Built by CI engineers for your automation needs. Here are some highlights of Tart:
@@ -8,64 +8,52 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
* Use Tart Packer Plugin to automate VM creation.
* Easily integrates with any CI system.
Tart powers [Cirrus Runners](https://cirrus-runners.app/)
service — a drop-in replacement for the standard GitHub-hosted runners, offering 2-3 times better performance for a fraction of the price.
<p align="center">
<a href="https://cirrus-runners.app/?utm_source=github&utm_medium=referral" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/CirrusRunnersForGHA.png" height="65"/>
</a>
</p>
Many companies are using Tart in their internal setups. Here are a few of them:
Many companies are using Tart in their internal setups. Here are just a few of them:
<p align="center">
<a href="https://atlassian.com/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Atlassian.png" height="65"/>
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Atlassian.png" height="65"/>
</a>
<a href="https://www.figma.com/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Figma.png" height="65"/>
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Figma.png" height="65"/>
</a>
<a href="https://mullvad.net/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Mullvad.png" height="65"/>
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Mullvad.png" height="65"/>
</a>
<a href="https://krisp.ai/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
</a>
<a href="https://testingbot.com/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/TestingBot.png" height="65"/>
<img src="https://github.com/openai/tart/raw/main/Resources/Users/TestingBot.png" height="65"/>
</a>
<a href="https://symflower.com/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Symflower.png" height="65"/>
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Symflower.png" height="65"/>
</a>
<a href="https://transloadit.com/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Transloadit.png" height="65"/>
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Transloadit.png" height="65"/>
</a>
<a href="https://cirrus-ci.org/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/CirrusCI.png" height="65"/>
<img src="https://github.com/openai/tart/raw/main/Resources/Users/CirrusCI.png" height="65"/>
</a>
<a href="https://www.pitsdatarecovery.net/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/PITSGlobalDataRecoveryServices.png" height="65"/>
<img src="https://github.com/openai/tart/raw/main/Resources/Users/PITSGlobalDataRecoveryServices.png" height="65"/>
</a>
<a href="https://expo.dev/" target=_blank>
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Expo.png" height="65"/>
</a>
</p>
**Note:** If your company or project is using Tart please consider [sharing with the community](https://github.com/cirruslabs/tart/discussions/857).
<p align="center">
<a href="https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws?utm_source=github&utm_medium=referral" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/AWSMarkeplaceLogo.png" height="90"/>
</a>
</p>
**Note:** If your company or project is using Tart please consider [sharing with the community](https://github.com/openai/tart/discussions/857).
## Usage
Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura) or later (will download a 25 GB image):
```bash
brew install cirruslabs/cli/tart
tart clone ghcr.io/cirruslabs/macos-sonoma-base:latest sonoma-base
tart run sonoma-base
brew install openai/tools/tart
tart clone ghcr.io/cirruslabs/macos-tahoe-base:latest tahoe-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/openai/tart/discussions)
for remaining questions.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

+23 -17
View File
@@ -2,22 +2,28 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>tart</string>
<key>CFBundleIdentifier</key>
<string>org.cirruslabs.tart</string>
<key>CFBundleExecutable</key>
<string>tart</string>
<key>LSBackgroundOnly</key>
<string>1</string>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon.png</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>CFBundleName</key>
<string>Tart</string>
<key>CFBundleDisplayName</key>
<string>Tart</string>
<key>CFBundleIdentifier</key>
<string>com.github.cirruslabs.tart</string>
<key>CFBundleExecutable</key>
<string>tart</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.developer-tools</string>
<key>CFBundleIconFile</key>
<string>UPW Tart</string>
<key>CFBundleIconName</key>
<string>UPW Tart</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>Access to OCI registries on the local network</string>
</dict>
</plist>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

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.

After

Width:  |  Height:  |  Size: 3.9 KiB

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.
Binary file not shown.
+1 -1
View File
@@ -1,5 +1,5 @@
struct CI {
private static let rawVersion = "${CIRRUS_TAG}"
private static let rawVersion = "${VERSION}"
static var version: String {
rawVersion.expanded() ? rawVersion : "SNAPSHOT"
+21 -11
View File
@@ -9,12 +9,10 @@ struct Clone: AsyncParsableCommand {
Creates a local virtual machine by cloning either a remote or another local virtual machine.
Due to copy-on-write magic in Apple File System, a cloned VM won't actually claim all the space right away.
Only changes to a cloned disk will be written and claim new space. By default, Tart checks available capacity
in Tart's home directory and checks if there is enough space for the worst possible scenario: when the whole disk
will be modified.
Only changes to a cloned disk will be written and claim new space. This also speeds up clones enormously.
This behaviour can be disabled by setting TART_NO_AUTO_PRUNE environment variable. This might be helpful
for use cases when the original image is very big and a workload is known to only modify a fraction of the cloned disk.
By default, Tart checks available capacity in Tart's home directory and tries to reclaim minimum possible storage for the cloned image
to fit. This behaviour is called "automatic pruning" and can be disabled by setting TART_NO_AUTO_PRUNE environment variable.
"""
)
@@ -30,6 +28,12 @@ struct Clone: AsyncParsableCommand {
@Option(help: "network concurrency to use when pulling a remote VM from the OCI-compatible registry")
var concurrency: UInt = 4
@Flag(help: .hidden)
var deduplicate: Bool = false
@Option(help: ArgumentHelp("limit automatic pruning to n gigabytes", valueName: "n"))
var pruneLimit: UInt = 100
func validate() throws {
if newName.contains("/") {
throw ValidationError("<new-name> should be a local name")
@@ -41,13 +45,13 @@ struct Clone: AsyncParsableCommand {
}
func run() async throws {
let ociStorage = VMStorageOCI()
let localStorage = VMStorageLocal()
let ociStorage = try VMStorageOCI()
let localStorage = try VMStorageLocal()
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
// Pull the VM in case it's OCI-based and doesn't exist locally yet
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency)
try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate)
}
let sourceVM = try VMStorageHelper.open(sourceName)
@@ -70,10 +74,16 @@ struct Clone: AsyncParsableCommand {
try lock.unlock()
// APFS is doing copy-on-write so the above cloning operation (just copying files on disk)
// APFS is doing copy-on-write, so the above cloning operation (just copying files on disk)
// is not actually claiming new space until the VM is started and it writes something to disk.
// So once we clone the VM let's try to claim a little bit of space for the VM to run.
try Prune.reclaimIfNeeded(UInt64(sourceVM.allocatedSizeBytes()), sourceVM)
//
// So, once we clone the VM let's try to claim the rest of space for the VM to run without errors.
let unallocatedBytes = try sourceVM.sizeBytes() - sourceVM.allocatedSizeBytes()
// Avoid reclaiming an excessive amount of disk space.
let reclaimBytes = min(unallocatedBytes, Int(pruneLimit) * 1024 * 1024 * 1024)
if reclaimBytes > 0 {
try Prune.reclaimIfNeeded(UInt64(reclaimBytes), sourceVM)
}
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
})
+11 -3
View File
@@ -10,7 +10,7 @@ struct Create: AsyncParsableCommand {
@Argument(help: "VM name")
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?
@Flag(help: "create a Linux VM")
@@ -19,6 +19,9 @@ struct Create: AsyncParsableCommand {
@Option(help: ArgumentHelp("Disk size in GB"))
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 {
if fromIPSW == nil && !linux {
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!")
}
#endif
// Validate disk format support
if !diskFormat.isSupported {
throw ValidationError("Disk format '\(diskFormat.rawValue)' is not supported on this system.")
}
}
func run() async throws {
@@ -58,12 +66,12 @@ struct Create: AsyncParsableCommand {
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
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)
+225
View File
@@ -0,0 +1,225 @@
import ArgumentParser
import Foundation
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)
}
// 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())
}
// 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 {
let controlSocketPath = vmDir.controlSocketURL.relativePath
try await withGuestAgentChannel(unixDomainSocketPath: controlSocketPath) { channel in
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 {
// EOF: unregister the handler, otherwise the fd stays permanently
// "readable" and Foundation re-invokes us in a tight loop, burning
// 100% of a core for the rest of the command's lifetime
handle.readabilityHandler = nil
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))
var name: String
@Argument(help: "Path to the destination file.")
@Argument(help: "Path to the destination file.", completion: .file())
var path: String?
func run() async throws {
+2 -1
View File
@@ -6,6 +6,7 @@ fileprivate struct VMInfo: Encodable {
let CPU: Int
let Memory: UInt64
let Disk: Int
let DiskFormat: String
let Size: String
let Display: String
let Running: Bool
@@ -26,7 +27,7 @@ struct Get: AsyncParsableCommand {
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
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))
}
}
+27 -12
View File
@@ -2,12 +2,11 @@ import ArgumentParser
import Foundation
import Network
import SystemConfiguration
import Sentry
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 {
@@ -19,13 +18,11 @@ struct IP: AsyncParsableCommand {
@Option(help: "Number of seconds to wait for a potential VM booting")
var wait: UInt16 = 0
@Option(help: ArgumentHelp("Strategy for resolving IP address: dhcp or arp",
@Option(help: ArgumentHelp("Strategy for resolving IP address",
discussion: """
By default, Tart is looking up and parsing DHCP lease file to determine the IP of the VM.\n
This method is fast and the most reliable but only returns local IP adresses.\n
Alternatively, Tart can call external `arp` executable and parse it's output.\n
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`.
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
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
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.
"""))
var resolver: IPResolutionStrategy = .dhcp
@@ -34,14 +31,16 @@ struct IP: AsyncParsableCommand {
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
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"
if try !vmDir.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)"
}
@@ -51,7 +50,7 @@ struct IP: AsyncParsableCommand {
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)!
repeat {
@@ -64,6 +63,22 @@ struct IP: AsyncParsableCommand {
if let leases = try Leases(), let ip = leases.ResolveMACAddress(macAddress: vmMACAddress) {
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
+3 -3
View File
@@ -4,10 +4,10 @@ import Foundation
struct Import: AsyncParsableCommand {
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
@Argument(help: "Destination VM name.")
@Argument(help: "Destination VM name.", completion: .custom(completeLocalMachines))
var name: String
func validate() throws {
@@ -17,7 +17,7 @@ struct Import: AsyncParsableCommand {
}
func run() async throws {
let localStorage = VMStorageLocal()
let localStorage = try VMStorageLocal()
// Create a temporary VM directory to which we will load the export file
let tmpVMDir = try VMDirectory.temporary()
+32 -3
View File
@@ -7,6 +7,7 @@ fileprivate struct VMInfo: Encodable {
let Name: String
let Disk: Int
let Size: Int
let Accessed: String
let Running: Bool
let State: String
}
@@ -17,7 +18,7 @@ struct List: AsyncParsableCommand {
@Option(help: ArgumentHelp("Only display VMs from the specified source (e.g. --source local, --source oci)."))
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
@Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names."))
@@ -38,13 +39,29 @@ struct List: AsyncParsableCommand {
if source == nil || source == "local" {
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
try VMInfo(Source: "local", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), 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" {
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
try VMInfo(Source: "OCI", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), 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
)
})
}
@@ -60,4 +77,16 @@ struct List: AsyncParsableCommand {
private func sortedInfos(_ infos: [VMInfo]) -> [VMInfo] {
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 {
let userFriendlyName = "static dictionary credentials provider"
var credentials: Dictionary<String, (String, String)>
init(_ credentials: Dictionary<String, (String, String)>) {
+24 -23
View File
@@ -1,13 +1,13 @@
import ArgumentParser
import Dispatch
import Sentry
import OpenTelemetryApi
import SwiftUI
import SwiftDate
struct Prune: AsyncParsableCommand {
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"
@Option(help: ArgumentHelp("Remove entries that were last accessed more than n days ago",
@@ -53,9 +53,9 @@ struct Prune: AsyncParsableCommand {
switch entries {
case "caches":
prunableStorages = [VMStorageOCI(), try IPSWCache()]
prunableStorages = [try VMStorageOCI(), try IPSWCache()]
case "vms":
prunableStorages = [VMStorageLocal()]
prunableStorages = [try VMStorageLocal()]
default:
throw ValidationError("unsupported --entries value, please specify either \"caches\" or \"vms\"")
}
@@ -109,9 +109,10 @@ struct Prune: AsyncParsableCommand {
return
}
SentrySDK.configureScope { scope in
scope.setContext(value: ["requiredBytes": requiredBytes], key: "Prune")
}
OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
key: "prune.required-bytes",
value: .int(Int(requiredBytes))
)
// Figure out how much disk space is available
let attrs = try Config().tartCacheDir.resourceValues(forKeys: [
@@ -123,18 +124,14 @@ struct Prune: AsyncParsableCommand {
UInt64(attrs.volumeAvailableCapacityForImportantUsage!)
)
SentrySDK.configureScope { scope in
scope.setContext(value: [
"volumeAvailableCapacity": attrs.volumeAvailableCapacity!,
"volumeAvailableCapacityForImportantUsage": attrs.volumeAvailableCapacityForImportantUsage!,
"volumeAvailableCapacityCalculated": volumeAvailableCapacityCalculated
], key: "Prune")
}
OpenTelemetry.instance.contextProvider.activeSpan?.setAttributes([
"prune.volume-available-capacity-bytes": .int(Int(attrs.volumeAvailableCapacity!)),
"prune.volume-available-capacity-for-important-usage-bytes": .int(Int(attrs.volumeAvailableCapacityForImportantUsage!)),
"prune.volume-available-capacity-calculated": .int(Int(volumeAvailableCapacityCalculated)),
])
if volumeAvailableCapacityCalculated <= 0 {
SentrySDK.capture(message: "Zero volume capacity reported") { scope in
scope.setLevel(.warning)
}
OpenTelemetry.instance.contextProvider.activeSpan?.addEvent(name: "Zero volume capacity reported")
return
}
@@ -149,10 +146,10 @@ struct Prune: AsyncParsableCommand {
}
private static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws {
let transaction = SentrySDK.startTransaction(name: "Pruning cache", operation: "prune", bindToScope: true)
defer { transaction.finish() }
let span = OTel.shared.tracer.spanBuilder(spanName: "prune").startSpan()
defer { span.end() }
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
let prunableStorages: [PrunableStorage] = [try VMStorageOCI(), try IPSWCache()]
let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() < $1.accessDate() }
@@ -177,13 +174,17 @@ struct Prune: AsyncParsableCommand {
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()
}
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte);
OpenTelemetry.instance.contextProvider.activeSpan?
.addEvent(name: "Reclaimed \(cacheReclaimedBytes) bytes")
}
}
+7 -4
View File
@@ -9,8 +9,8 @@ struct Pull: AsyncParsableCommand {
Pulls a virtual machine from a remote OCI-compatible registry. Supports authorization via Keychain (see "tart login --help"),
Docker credential helpers defined in ~/.docker/config.json or via TART_REGISTRY_USERNAME/TART_REGISTRY_PASSWORD environment variables.
By default, Tart checks available capacity in Tart's home directory and tries to reclaim minimum possible storage for the remote image to fit via "tart prune".
This behaviour can be disabled by setting TART_NO_AUTO_PRUNE environment variable.
By default, Tart checks available capacity in Tart's home directory and tries to reclaim minimum possible storage for the remote image
to fit. This behaviour is called "automatic pruning" and can be disabled by setting TART_NO_AUTO_PRUNE environment variable.
"""
)
@@ -23,6 +23,9 @@ struct Pull: AsyncParsableCommand {
@Option(help: "network concurrency to use when pulling a remote VM from the OCI-compatible registry")
var concurrency: UInt = 4
@Flag(help: .hidden)
var deduplicate: Bool = false
func validate() throws {
if concurrency < 1 {
throw ValidationError("network concurrency cannot be less than 1")
@@ -32,7 +35,7 @@ struct Pull: AsyncParsableCommand {
func run() async throws {
// Be more liberal when accepting local image as argument,
// see https://github.com/cirruslabs/tart/issues/36
if VMStorageLocal().exists(remoteName) {
if try VMStorageLocal().exists(remoteName) {
print("\"\(remoteName)\" is a local image, nothing to pull here!")
return
@@ -43,6 +46,6 @@ struct Pull: AsyncParsableCommand {
defaultLogger.appendNewLine("pulling \(remoteName)...")
try await VMStorageOCI().pull(remoteName, registry: registry, concurrency: concurrency)
try await VMStorageOCI().pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate)
}
}
+32 -4
View File
@@ -15,6 +15,9 @@ struct Push: AsyncParsableCommand {
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
var insecure: Bool = false
@Option(help: "network concurrency to use when pushing a local VM to the OCI-compatible registry")
var concurrency: UInt = 4
@Option(help: ArgumentHelp("chunk size in MB if registry supports chunked uploads",
discussion: """
By default monolithic method is used for uploading blobs to the registry but some registries support a more efficient chunked method.
@@ -23,15 +26,17 @@ struct Push: AsyncParsableCommand {
"""))
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",
discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
var populateCache: Bool = false
func run() async throws {
let ociStorage = VMStorageOCI()
let ociStorage = try VMStorageOCI()
let localVMDir = try VMStorageHelper.open(localName)
let lock = try localVMDir.lock()
if try !lock.trylock() {
@@ -77,7 +82,8 @@ struct Push: AsyncParsableCommand {
registry: registry,
references: references,
chunkSizeMb: chunkSize,
diskFormat: diskFormat
concurrency: concurrency,
labels: parseLabels()
)
// Populate the local cache (if requested)
if populateCache {
@@ -111,6 +117,28 @@ struct Push: AsyncParsableCommand {
return RemoteName(host: registry.host!, namespace: registry.namespace,
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 {
+1 -1
View File
@@ -17,7 +17,7 @@ struct Rename: AsyncParsableCommand {
}
func run() async throws {
let localStorage = VMStorageLocal()
let localStorage = try VMStorageLocal()
if !localStorage.exists(name) {
throw ValidationError("failed to rename a non-existent local VM: \(name)")
+535 -130
View File
@@ -4,7 +4,7 @@ import Darwin
import Dispatch
import SwiftUI
import Virtualization
import Sentry
import OpenTelemetryApi
import System
var vm: VM?
@@ -12,6 +12,56 @@ var vm: VM?
struct IPNotFound: Error {
}
@available(macOS 14, *)
extension VZDiskSynchronizationMode {
public init(_ description: String) throws {
switch description {
case "none":
self = .none
case "full":
self = .full
case "":
self = .full
default:
throw RuntimeError.VMConfigurationError("unsupported disk synchronization mode: \"\(description)\"")
}
}
}
extension VZDiskImageSynchronizationMode {
public init(_ description: String) throws {
switch description {
case "none":
self = .none
case "fsync":
self = .fsync
case "full":
self = .full
case "":
self = .full
default:
throw RuntimeError.VMConfigurationError("unsupported disk image synchronization mode: \"\(description)\"")
}
}
}
extension VZDiskImageCachingMode {
public init?(_ description: String) throws {
switch description {
case "automatic":
self = .automatic
case "cached":
self = .cached
case "uncached":
self = .uncached
case "":
return nil
default:
throw RuntimeError.VMConfigurationError("unsupported disk image caching mode: \"\(description)\"")
}
}
}
struct Run: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Run a VM")
@@ -31,7 +81,7 @@ struct Run: AsyncParsableCommand {
@Option(help: ArgumentHelp(
"Attach an externally created serial console",
discussion: "Alternative to `--serial` flag for programmatic integrations."
))
), completion: .file())
var serialPath: String?
@Flag(help: ArgumentHelp("Force open a UI window, even when VNC is enabled.", visibility: .private))
@@ -42,7 +92,7 @@ struct Run: AsyncParsableCommand {
@Flag(help: ArgumentHelp(
"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
#if arch(arm64)
@@ -67,7 +117,7 @@ struct Run: AsyncParsableCommand {
var vncExperimental: Bool = false
@Option(help: ArgumentHelp("""
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\" --disk=\"/dev/disk0\" --disk "ghcr.io/cirruslabs/xcode:16.0:ro" --disk=\"nbd://localhost:10809/myDisk\")
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: """
The disk attachment can be a:
@@ -76,14 +126,21 @@ struct Run: AsyncParsableCommand {
* remote VM name whose disk will be mounted
* Network Block Device (NBD) URL
Options are comma-separated and are as follows:
* ro — attach the specified disk in read-only mode instead of the default read-write (e.g. --disk="disk.img:ro")
* sync=none — disable data synchronization with the permanent storage to increase performance at the cost of a higher chance of data loss (e.g. --disk="disk.img:sync=none")
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
""", valueName: "path[:ro]"))
Warning: after running the chown command above, all software running under the current user will be able to access /dev/diskX. If that violates your threat model, we recommend avoiding mounting block devices altogether.
""", valueName: "path[:options]"), completion: .file())
var disk: [String] = []
#if arch(arm64)
@@ -102,7 +159,7 @@ struct Run: AsyncParsableCommand {
#endif
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.
Options are comma-separated and are as follows:
@@ -114,9 +171,12 @@ 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.
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] = []
@Flag(help: ArgumentHelp("Enable nested virtualization if possible"))
var nested: Bool = false
@Option(help: ArgumentHelp("""
Use bridged networking instead of the default shared (NAT) networking \n(e.g. --net-bridged=en0 or --net-bridged=\"Wi-Fi\")
""", discussion: """
@@ -124,16 +184,92 @@ struct Run: AsyncParsableCommand {
""", valueName: "interface name"))
var netBridged: [String] = []
@Flag(help: ArgumentHelp("Use software networking instead of the default shared (NAT) networking",
discussion: "Learn how to configure Softnet for use with Tart here: https://github.com/cirruslabs/softnet"))
@Flag(help: ArgumentHelp("Use software networking provided by Softnet instead of the default shared (NAT) networking",
discussion: """
Softnet provides better network isolation and alleviates DHCP shortage on production systems. Tart invokes Softnet when this option is specified as a sub-process and communicates with it over socketpair(2).
It is essentially a userspace packet filter which restricts the VM networking and prevents a class of security issues, such as ARP spoofing. By default, the VM will only be able to:
* send traffic from its own MAC-address
* send traffic from the IP-address assigned to it by the DHCP
* send traffic to globally routable IPv4 addresses
* send traffic to gateway IP of the vmnet bridge (this would normally be \"bridge100\" interface)
* receive any incoming traffic
In addition, Softnet tunes macOS built-in DHCP server to decrease its lease time from the default 86,400 seconds (one day) to 600 seconds (10 minutes). This is especially important when you use Tart to clone and run a lot of ephemeral VMs over a period of one day.
More on Softnet here: https://github.com/cirruslabs/softnet
"""))
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)", valueName: "comma-separated CIDRs"))
@Option(help: ArgumentHelp("Comma-separated list of CIDRs to allow the traffic to when using Softnet isolation (e.g. --net-softnet-allow=192.168.0.0/24)", discussion: """
This option allows you bypass the private IPv4 address space restrictions imposed by --net-softnet.
For example, you can allow the VM to communicate with the local network with e.g. --net-softnet-allow=10.0.0.0/16 or with --net-softnet-allow=0.0.0.0/0 to completely disable the destination based restrictions, including VMs bridge isolation.
When used with --net-softnet-block, the longest prefix match always wins. In case the same prefix is both allowed and blocked, blocking takes precedence.
Implies --net-softnet.
""", valueName: "comma-separated CIDRs"))
var netSoftnetAllow: String?
@Option(help: ArgumentHelp("Comma-separated list of CIDRs to block the traffic to when using Softnet isolation (e.g. --net-softnet-block=66.66.0.0/16)", discussion: """
This option allows you to tighten the IPv4 address space restrictions imposed by --net-softnet even further.
For example --net-softnet-block=0.0.0.0/0 may be used to establish a default deny policy that is further relaxed with --net-softnet-allow.
When used with --net-softnet-allow, the longest prefix match always wins. In case the same prefix is both allowed and blocked, blocking takes precedence.
Implies --net-softnet.
""", valueName: "comma-separated CIDRs"))
var netSoftnetBlock: String?
@Option(help: ArgumentHelp("Connected Unix stream socket file descriptor to use for the Softnet control channel (e.g. --net-softnet-control-fd=3)", discussion: """
This option enables the Softnet control channel on an inherited Unix stream socket. It can be used to dynamically replace Softnet allow and block lists while the VM is running.
The file descriptor must be greater than 2. Implies --net-softnet.
""", valueName: "file descriptor"))
var netSoftnetControlFd: Int32?
@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:
* EXTERNAL_PORT:INTERNAL_PORT — forward TCP traffic from the EXTERNAL_PORT on a host's egress interface (automatically detected and could be Wi-Fi, Ethernet and a VPN interface) to the INTERNAL_PORT on guest's IP (as reported by "tart ip")
Note that for the port forwarding to work correctly:
* the software in guest listening on INTERNAL_PORT should either listen on 0.0.0.0 or on an IP address assigned to that guest
* connection to the EXTERNAL_PORT should be performed from the local network that the host is attached to or from the internet, it's not possible to connect to that forwarded port from the host itself
Another thing to keep in mind is that regular Softnet restrictions will still apply even to port forwarding. So if you're planning to access your VM from local network, and your local network is 192.168.0.0/24, for example, then add --net-softnet-allow=192.168.0.0/24. If you only need port forwarding, to completely disable Softnet restrictions you can use --net-softnet-allow=0.0.0.0/0.
Implies --net-softnet.
""", valueName: "comma-separated port specifications"))
var netSoftnetExpose: String?
@Flag(help: ArgumentHelp("Restrict network access to the host-only network"))
var netHost: Bool = false
@Option(help: ArgumentHelp("Set the root disk options (e.g. --root-disk-opts=\"ro\" or --root-disk-opts=\"caching=cached,sync=none\")",
discussion: """
Options are comma-separated and are as follows:
* ro — attach the root disk in read-only mode instead of the default read-write (e.g. --root-disk-opts="ro")
* sync=none — disable data synchronization with the permanent storage to increase performance at the cost of a higher chance of data loss (e.g. --root-disk-opts="sync=none")
* sync=fsync — enable data synchronization with the permanent storage, but don't ensure that it was actually written (e.g. --root-disk-opts="sync=fsync")
* sync=full — enable data synchronization with the permanent storage and ensure that it was actually written (e.g. --root-disk-opts="sync=full")
* caching=automatic — allows the virtualization framework to automatically determine whether to enable data caching
* caching=cached — enabled data caching
* caching=uncached — disables data caching
""", valueName: "options"))
var rootDiskOpts: String = ""
#if arch(arm64)
@Flag(help: ArgumentHelp("Disables audio and entropy devices and switches to only Mac-specific input devices.", discussion: "Useful for running a VM that can be suspended via \"tart suspend\"."))
#endif
@@ -145,12 +281,50 @@ struct Run: AsyncParsableCommand {
#endif
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
#if arch(arm64) && compiler(>=6.4)
@Option(help: ArgumentHelp("Provision a macOS guest on first boot using the guest provisioning API", discussion: """
Takes a comma-separated list of key=value pairs that configure the initial setup of a macOS guest
Requires the host to be running macOS 27 (or newer) and only takes effect on the first boot after
creation of a macOS 27 (or newer) guest VM.
Supported keys (matching VZMacGuestProvisioningOptions):
* fullName=<NAME> — the person's full name to configure
* username=<USERNAME> — the username for logging into the guest
* password=<PASSWORD> — the password to configure for the guest
* logsInAutomatically=true|false — whether to automatically log the person in at startup
* enablesRemoteLogin=true|false — whether to enable Remote Login (SSH) in the guest
""", valueName: "key=value,..."))
var provisioningOpts: String?
#endif
mutating func validate() throws {
if vnc && vncExperimental {
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
}
// check that not more than one network option is specified
// Automatically enable --net-softnet when any of its related options are specified
if netSoftnetAllow != nil || netSoftnetBlock != nil || netSoftnetExpose != nil || netSoftnetControlFd != nil {
netSoftnet = true
}
// Check that no more than one network option is specified
var netFlags = 0
if netBridged.count > 0 { netFlags += 1 }
if netSoftnet { netFlags += 1 }
@@ -168,7 +342,15 @@ struct Run: AsyncParsableCommand {
throw ValidationError("--captures-system-keys can only be used with the default VM view")
}
let localStorage = VMStorageLocal()
if nested {
if #unavailable(macOS 15) {
throw ValidationError("Nested virtualization is supported on hosts starting with macOS 15 (Sequoia), and later.")
} else if !VZGenericPlatformConfiguration.isNestedVirtualizationSupported {
throw ValidationError("Nested virtualization is available for Mac with the M3 chip, and later.")
}
}
let localStorage = try VMStorageLocal()
let vmDir = try localStorage.open(name)
if try vmDir.state() == .Suspended {
suspendable = true
@@ -176,14 +358,42 @@ struct Run: AsyncParsableCommand {
if suspendable {
let config = try VMConfig.init(fromURL: vmDir.configURL)
if (config.platform is Linux) {
if !(config.platform is PlatformSuspendable) {
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")
}
}
#if arch(arm64) && compiler(>=6.4)
if provisioningOpts != nil {
if #unavailable(macOS 27) {
throw ValidationError("--provisioning-opts requires the host to be running macOS 27 (or newer)")
}
let config = try VMConfig.init(fromURL: vmDir.configURL)
if config.os != .darwin {
throw ValidationError("--provisioning-opts can only be used with macOS VMs")
}
}
#endif
for disk in disk {
if disk.hasSuffix("-amd64.iso") {
throw ValidationError("Seems you have a disk targeting x86 architecture (hence amd64 in the name). Please use an 'arm64' version of the disk.")
@@ -192,10 +402,16 @@ struct Run: AsyncParsableCommand {
}
@MainActor
func run() async throws {
let localStorage = VMStorageLocal()
func runOnMainThread() throws {
let localStorage = try VMStorageLocal()
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)
try storageLock.lock()
// check if there is a running VM with the same MAC address
@@ -213,8 +429,6 @@ struct Run: AsyncParsableCommand {
try Softnet.configureSUIDBitIfNeeded()
}
let additionalDiskAttachments = try additionalDiskAttachments()
var serialPorts: [VZSerialPortConfiguration] = []
if serial {
let tty_fd = createPTY()
@@ -233,15 +447,29 @@ struct Run: AsyncParsableCommand {
serialPorts.append(createSerialPortConfiguration(tty_read!, tty_write!))
}
// Parse root disk options
let diskOptions = DiskOptions(rootDiskOpts)
// Parse guest provisioning options
#if arch(arm64) && compiler(>=6.4)
let provisioning = try provisioningOpts.map { try GuestProvisioningOptions($0) }
#endif
vm = try VM(
vmDir: vmDir,
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
additionalStorageDevices: additionalDiskAttachments,
additionalStorageDevices: try additionalDiskAttachments(),
directorySharingDevices: directoryShares() + rosettaDirectoryShare(),
serialPorts: serialPorts,
suspendable: suspendable,
nested: nested,
audio: !noAudio,
clipboard: !noClipboard
clipboard: !noClipboard,
sync: VZDiskImageSynchronizationMode(diskOptions.syncModeRaw),
caching: VZDiskImageCachingMode(diskOptions.cachingModeRaw),
noTrackpad: noTrackpad,
noPointer: noPointer,
noKeyboard: noKeyboard
)
let vncImpl: VNC? = try {
@@ -291,7 +519,39 @@ struct Run: AsyncParsableCommand {
}
#endif
try await vm!.start(recovery: recovery, resume: resume)
do {
#if arch(arm64) && compiler(>=6.4)
try await vm!.start(recovery: recovery, resume: resume, provisioning: provisioning)
#else
try await vm!.start(recovery: recovery, resume: resume)
#endif
} catch let error as VZError {
if error.code == .virtualMachineLimitExceeded {
var hint = ""
do {
let runningVMs: [String] = try localStorage.list().compactMap { (name, vmDir) in
if try !vmDir.running() {
return nil
}
return name
}
if !runningVMs.isEmpty {
let runningVMsJoined = runningVMs.joined(separator: ", ")
hint = " (other running VMs: \(runningVMsJoined))"
}
} catch {
// we can't provide any hint
}
throw RuntimeError.VirtualMachineLimitExceeded(hint)
}
throw error
}
if let vncImpl = vncImpl {
let vncURL = try await vncImpl.waitForURL(netBridged: !netBridged.isEmpty)
@@ -304,20 +564,27 @@ struct Run: AsyncParsableCommand {
}
}
if #available(macOS 14, *) {
ErrorReportingTask("Failed to run control socket") {
try await ControlSocket(vmDir.controlSocketURL).run()
}
}
try await vm!.run()
if let vncImpl = vncImpl {
try vncImpl.stop()
}
OTel.shared.flush()
Foundation.exit(0)
} catch {
// Capture the error into Sentry
SentrySDK.capture(error: error)
SentrySDK.flush(timeout: 2.seconds.timeInterval)
// Capture the error into OpenTelemetry
OpenTelemetry.instance.contextProvider.activeSpan?.recordException(error)
fputs("\(error)\n", stderr)
OTel.shared.flush()
Foundation.exit(1)
}
}
@@ -351,12 +618,14 @@ struct Run: AsyncParsableCommand {
} else {
print(RuntimeError.SuspendFailed("this functionality is only supported on macOS 14 (Sonoma) or newer"))
OTel.shared.flush()
Foundation.exit(1)
}
#endif
} catch (let e) {
print(RuntimeError.SuspendFailed(e.localizedDescription))
OTel.shared.flush()
Foundation.exit(1)
}
}
@@ -368,7 +637,7 @@ struct Run: AsyncParsableCommand {
signal(SIGUSR2, SIG_IGN)
let sigusr2Src = DispatchSource.makeSignalSource(signal: SIGUSR2)
sigusr2Src.setEventHandler {
Task {
ErrorReportingTask("Failed to request guest OS to stop") {
print("Requesting guest OS to stop...")
try vm!.virtualMachine.requestStop()
}
@@ -377,8 +646,10 @@ struct Run: AsyncParsableCommand {
let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics
if noGraphics || useVNCWithoutGraphics {
// enter the main even loop, without bringing up any UI,
// and just wait for the VM to exit.
// Enter the main event loop without bringing up any UI,
// waiting for the VM to exit.
NSApplication.shared.setActivationPolicy(.prohibited)
NSApplication.shared.run()
} else {
runUI(suspendable, captureSystemKeys)
@@ -406,16 +677,24 @@ struct Run: AsyncParsableCommand {
softnetExtraArguments += ["--allow", netSoftnetAllow]
}
if let netSoftnetBlock = netSoftnetBlock {
softnetExtraArguments += ["--block", netSoftnetBlock]
}
if let netSoftnetExpose = netSoftnetExpose {
softnetExtraArguments += ["--expose", netSoftnetExpose]
}
if netSoftnet {
let config = try VMConfig.init(fromURL: vmDir.configURL)
return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: softnetExtraArguments)
return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: softnetExtraArguments, controlFD: netSoftnetControlFd)
}
if netHost {
let config = try VMConfig.init(fromURL: vmDir.configURL)
return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: ["--vm-net-type", "host"] + softnetExtraArguments)
return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: ["--vm-net-type", "host"] + softnetExtraArguments, controlFD: netSoftnetControlFd)
}
if netBridged.count > 0 {
@@ -449,90 +728,9 @@ struct Run: AsyncParsableCommand {
}
func additionalDiskAttachments() throws -> [VZStorageDeviceConfiguration] {
var result: [VZStorageDeviceConfiguration] = []
let readOnlySuffix = ":ro"
let expandedDiskPaths = disk.map { NSString(string:$0).expandingTildeInPath }
for rawDisk in expandedDiskPaths {
let diskReadOnly = rawDisk.hasSuffix(readOnlySuffix)
let diskPath = diskReadOnly ? String(rawDisk.prefix(rawDisk.count - readOnlySuffix.count)) : rawDisk
let diskURL = URL(string: diskPath)
if (["nbd", "nbds", "nbd+unix", "nbds+unix"].contains(diskURL?.scheme)) {
guard #available(macOS 14, *) else {
throw UnsupportedOSError("attaching Network Block Devices", "are")
}
let nbdAttachment = try VZNetworkBlockDeviceStorageDeviceAttachment(
url: diskURL!,
timeout: 30,
isForcedReadOnly: diskReadOnly,
synchronizationMode: VZDiskSynchronizationMode.none
)
result.append(VZVirtioBlockDeviceConfiguration(attachment: nbdAttachment))
continue
}
let diskFileURL = URL(fileURLWithPath: diskPath)
if pathHasMode(diskPath, mode: S_IFBLK) {
guard #available(macOS 14, *) else {
throw UnsupportedOSError("attaching block devices", "are")
}
let fd = open(diskPath, diskReadOnly ? O_RDONLY : O_RDWR)
if fd == -1 {
let details = Errno(rawValue: CInt(errno))
switch details.rawValue {
case EBUSY:
throw RuntimeError.FailedToOpenBlockDevice(diskFileURL.url.path, "already in use, try umounting it via \"diskutil unmountDisk\" (when the whole disk) or \"diskutil umount\" (when mounting a single partition)")
case EACCES:
throw RuntimeError.FailedToOpenBlockDevice(diskFileURL.url.path, "permission denied, consider changing the disk's owner using \"sudo chown $USER \(diskFileURL.url.path)\" or run Tart as a superuser (see --disk help for more details on how to do that correctly)")
default:
throw RuntimeError.FailedToOpenBlockDevice(diskFileURL.url.path, "\(details)")
}
}
let blockAttachment = try VZDiskBlockDeviceStorageDeviceAttachment(fileHandle: FileHandle(fileDescriptor: fd, closeOnDealloc: true),
readOnly: diskReadOnly, synchronizationMode: .full)
result.append(VZVirtioBlockDeviceConfiguration(attachment: blockAttachment))
continue
}
// Support remote VM names in --disk command-line argument
if let remoteName = try? RemoteName(diskPath) {
let vmDir = try VMStorageOCI().open(remoteName)
// Unfortunately, VZDiskImageStorageDeviceAttachment does not support
// FileHandle, so we can't easily clone the disk, open it and unlink(2)
// to simplify the garbage collection, so use an intermediate directory.
let clonedDiskURL = try Config().tartTmpDir.appendingPathComponent("run-disk-\(UUID().uuidString)")
try FileManager.default.copyItem(at: vmDir.diskURL, to: clonedDiskURL)
let lock = try FileLock(lockURL: clonedDiskURL)
try lock.lock()
let diskImageAttachment = try VZDiskImageStorageDeviceAttachment(url: clonedDiskURL, readOnly: diskReadOnly)
result.append(VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment))
continue
}
// Error out if the disk is locked by the host (e.g. it was mounted in Finder),
// see https://github.com/cirruslabs/tart/issues/323 for more details.
if try !diskReadOnly && !FileLock(lockURL: diskFileURL).trylock() {
throw RuntimeError.DiskAlreadyInUse("disk \(diskFileURL.url.path) seems to be already in use, unmount it first in Finder")
}
let diskImageAttachment = try VZDiskImageStorageDeviceAttachment(
url: diskFileURL,
readOnly: diskReadOnly
)
result.append(VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment))
try disk.map {
try AdditionalDisk(parseFrom: $0).configuration
}
return result
}
func directoryShares() throws -> [VZDirectorySharingDeviceConfiguration] {
@@ -611,11 +809,16 @@ struct Run: AsyncParsableCommand {
}
}
// "tart run" drives an AppKit/SwiftUI run loop and therefore must own the main
// thread at the top level, so it opts out of Root's asynchronous command path.
// See Root.main() for the rationale.
extension Run: MainThreadCommand {}
struct MainApp: App {
static var suspendable: Bool = false
static var capturesSystemKeys: Bool = false
@NSApplicationDelegateAdaptor private var appDelegate: MinimalMenuAppDelegate
@NSApplicationDelegateAdaptor private var appDelegate: AppDelegate
var body: some Scene {
WindowGroup(vm!.name) {
@@ -651,13 +854,13 @@ struct MainApp: App {
CommandGroup(replacing: .appInfo) { AboutTart(config: vm!.config) }
CommandMenu("Control") {
Button("Start") {
Task { try await vm!.virtualMachine.start() }
ErrorReportingTask("Failed to start VM") { try await vm!.virtualMachine.start() }
}
Button("Stop") {
Task { try await vm!.virtualMachine.stop() }
ErrorReportingTask("Failed to stop VM") { try await vm!.virtualMachine.stop() }
}
Button("Request Stop") {
Task { try vm!.virtualMachine.requestStop() }
ErrorReportingTask("Failed to request VM stop") { try vm!.virtualMachine.requestStop() }
}
if #available(macOS 14, *) {
if (MainApp.suspendable) {
@@ -671,13 +874,8 @@ struct MainApp: App {
}
}
// The only way to fully remove Edit menu item.
class MinimalMenuAppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
let indexOfEditMenu = 2
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
func applicationDidFinishLaunching(_ : Notification) {
NSApplication.shared.mainMenu?.removeItem(at: indexOfEditMenu)
let nsApp = NSApplication.shared
nsApp.setActivationPolicy(.regular)
nsApp.activate(ignoringOtherApps: true)
@@ -735,12 +933,12 @@ struct VMView: NSViewRepresentable {
machineView.capturesSystemKeys = capturesSystemKeys
// Enable automatic display reconfiguration
// for guests that support it
// If not specified, enable automatic display
// reconfiguration for guests that support it
//
// This is disabled for Linux because of poor HiDPI
// support, which manifests in fonts being too small
if #available(macOS 14.0, *), vm.config.os != .linux {
if #available(macOS 14.0, *), vm.config.displayRefit ?? (vm.config.os != .linux) {
machineView.automaticallyReconfiguresDisplay = true
}
@@ -752,6 +950,213 @@ struct VMView: NSViewRepresentable {
}
}
struct AdditionalDisk {
let configuration: VZStorageDeviceConfiguration
init(parseFrom: String) throws {
let (diskPath, readOnly, syncModeRaw, cachingModeRaw) = Self.parseOptions(parseFrom)
self.configuration = try Self.craft(diskPath, readOnly: readOnly, syncModeRaw: syncModeRaw, cachingModeRaw: cachingModeRaw)
}
static func craft(_ diskPath: String, readOnly diskReadOnly: Bool, syncModeRaw: String, cachingModeRaw: String) throws -> VZStorageDeviceConfiguration {
let diskURL = URL(string: diskPath)
if (["nbd", "nbds", "nbd+unix", "nbds+unix"].contains(diskURL?.scheme)) {
guard #available(macOS 14, *) else {
throw UnsupportedOSError("attaching Network Block Devices", "are")
}
let nbdAttachment = try VZNetworkBlockDeviceStorageDeviceAttachment(
url: diskURL!,
timeout: 30,
isForcedReadOnly: diskReadOnly,
synchronizationMode: try VZDiskSynchronizationMode(syncModeRaw)
)
return VZVirtioBlockDeviceConfiguration(attachment: nbdAttachment)
}
// Expand the tilde (~) since at this point we're dealing with a local path,
// and "expandingTildeInPath" seems to corrupt the remote URLs like nbd://
let diskPath = NSString(string: diskPath).expandingTildeInPath
let diskFileURL = URL(fileURLWithPath: diskPath)
if pathHasMode(diskPath, mode: S_IFBLK) {
guard #available(macOS 14, *) else {
throw UnsupportedOSError("attaching block devices", "are")
}
let fd = open(diskPath, diskReadOnly ? O_RDONLY : O_RDWR)
if fd == -1 {
let details = Errno(rawValue: CInt(errno))
switch details.rawValue {
case EBUSY:
throw RuntimeError.FailedToOpenBlockDevice(diskFileURL.url.path, "already in use, try umounting it via \"diskutil unmountDisk\" (when the whole disk) or \"diskutil umount\" (when mounting a single partition)")
case EACCES:
throw RuntimeError.FailedToOpenBlockDevice(diskFileURL.url.path, "permission denied, consider changing the disk's owner using \"sudo chown $USER \(diskFileURL.url.path)\" or run Tart as a superuser (see --disk help for more details on how to do that correctly)")
default:
throw RuntimeError.FailedToOpenBlockDevice(diskFileURL.url.path, "\(details)")
}
}
let blockAttachment = try VZDiskBlockDeviceStorageDeviceAttachment(fileHandle: FileHandle(fileDescriptor: fd, closeOnDealloc: true),
readOnly: diskReadOnly, synchronizationMode: try VZDiskSynchronizationMode(syncModeRaw))
return VZVirtioBlockDeviceConfiguration(attachment: blockAttachment)
}
// Support remote VM names in --disk command-line argument
if let remoteName = try? RemoteName(diskPath) {
let vmDir = try VMStorageOCI().open(remoteName)
// Unfortunately, VZDiskImageStorageDeviceAttachment does not support
// FileHandle, so we can't easily clone the disk, open it and unlink(2)
// to simplify the garbage collection, so use an intermediate directory.
let clonedDiskURL = try Config().tartTmpDir.appendingPathComponent("run-disk-\(UUID().uuidString)")
try FileManager.default.copyItem(at: vmDir.diskURL, to: clonedDiskURL)
let lock = try FileLock(lockURL: clonedDiskURL)
try lock.lock()
let diskImageAttachment = try VZDiskImageStorageDeviceAttachment(url: clonedDiskURL, readOnly: diskReadOnly)
return VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment)
}
// Error out if the disk is locked by the host (e.g. it was mounted in Finder),
// see https://github.com/cirruslabs/tart/issues/323 for more details.
if try !diskReadOnly && !FileLock(lockURL: diskFileURL).trylock() {
throw RuntimeError.DiskAlreadyInUse("disk \(diskFileURL.url.path) seems to be already in use, unmount it first in Finder")
}
let diskImageAttachment = try VZDiskImageStorageDeviceAttachment(
url: diskFileURL,
readOnly: diskReadOnly,
cachingMode: try VZDiskImageCachingMode(cachingModeRaw) ?? .automatic,
synchronizationMode: try VZDiskImageSynchronizationMode(syncModeRaw)
)
return VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment)
}
static func parseOptions(_ parseFrom: String) -> (String, Bool, String, String) {
var arguments = parseFrom.split(separator: ":")
let options = DiskOptions(String(arguments.last!))
if options.foundAtLeastOneOption {
arguments.removeLast()
}
return (arguments.joined(separator: ":"), options.readOnly, options.syncModeRaw, options.cachingModeRaw)
}
}
struct DiskOptions {
var readOnly: Bool = false
var syncModeRaw: String = ""
var cachingModeRaw: String = ""
var foundAtLeastOneOption: Bool = false
init(_ parseFrom: String) {
let options = parseFrom.split(separator: ",")
for option in options {
switch true {
case option == "ro":
self.readOnly = true
self.foundAtLeastOneOption = true
case option.hasPrefix("sync="):
self.syncModeRaw = String(option.dropFirst("sync=".count))
self.foundAtLeastOneOption = true
case option.hasPrefix("caching="):
self.cachingModeRaw = String(option.dropFirst("caching=".count))
self.foundAtLeastOneOption = true
default:
continue
}
}
}
}
struct GuestProvisioningOptions {
var fullName: String?
var username: String?
var password: String?
var logsInAutomatically: Bool?
var enablesRemoteLogin: Bool?
init(_ parseFrom: String) throws {
for pair in parseFrom.split(separator: ",") {
let keyValue = pair.split(separator: "=", maxSplits: 1)
guard keyValue.count == 2 else {
throw RuntimeError.VMConfigurationError("invalid provisioning option \"\(pair)\", expected key=value")
}
let key = String(keyValue[0])
let value = String(keyValue[1])
switch key {
case "fullName":
self.fullName = value
case "username":
self.username = value
case "password":
self.password = value
case "logsInAutomatically":
self.logsInAutomatically = try Self.parseBool(key, value)
case "enablesRemoteLogin":
self.enablesRemoteLogin = try Self.parseBool(key, value)
default:
throw RuntimeError.VMConfigurationError("unsupported provisioning option \"\(key)\"")
}
}
}
private static func parseBool(_ key: String, _ value: String) throws -> Bool {
switch value {
case "true":
return true
case "false":
return false
default:
throw RuntimeError.VMConfigurationError("invalid value \"\(value)\" for provisioning option \"\(key)\", expected \"true\" or \"false\"")
}
}
}
#if arch(arm64) && compiler(>=6.4)
@available(macOS 27, *)
extension GuestProvisioningOptions {
func toVZMacGuestProvisioningOptions() throws -> VZMacGuestProvisioningOptions {
let options = VZMacGuestProvisioningOptions()
if let fullName = fullName {
options.fullName = fullName
}
if let username = username {
options.username = username
}
if let password = password {
options.password = password
}
if let logsInAutomatically = logsInAutomatically {
options.logsInAutomatically = logsInAutomatically
}
if let enablesRemoteLogin = enablesRemoteLogin {
options.enablesRemoteLogin = enablesRemoteLogin
}
try options.validate()
return options
}
}
#endif
struct DirectoryShare {
let name: String?
let path: URL
@@ -868,7 +1273,7 @@ struct DirectoryShare {
process.standardInput = inPipe
process.launch()
inPipe.fileHandleForWriting.write(response!.data)
try inPipe.fileHandleForWriting.write(contentsOf: response!.data)
try inPipe.fileHandleForWriting.close()
process.waitUntilExit()
+22 -13
View File
@@ -14,9 +14,12 @@ struct Set: AsyncParsableCommand {
@Option(help: "VM memory size in megabytes")
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?
@Flag(inversion: .prefixedNo, help: ArgumentHelp("Whether to automatically reconfigure the VM's display to fit the window"))
var displayRefit: Bool? = nil
@Flag(help: ArgumentHelp("Generate a new random MAC address for the VM."))
var randomMAC: Bool = false
@@ -30,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)",
discussion: """
Disk resizing works on most cloud-ready Linux distributions out-of-the box (e.g. Ubuntu Cloud Images
have the \"cloud-initramfs-growroot\" package installed that runs on boot) and on the rest of the
distributions by running the \"growpart\" or \"resize2fs\" commands.
For macOS, however, things are a bit more complicated: you need to remove the recovery partition
first and then run various \"diskutil\" commands, see Tart's packer plugin source code for more
details[1].
[1]: https://github.com/cirruslabs/packer-plugin-tart/blob/main/builder/tart/step_disk_resize.go
See https://tart.run/faq/#disk-resizing for more details.
"""))
var diskSize: UInt16?
@@ -61,15 +56,17 @@ struct Set: AsyncParsableCommand {
if (display.height > 0) {
vmConfig.display.height = display.height
}
vmConfig.display.unit = display.unit
}
vmConfig.displayRefit = displayRefit
if randomMAC {
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
}
#if arch(arm64)
if randomSerial {
let oldPlatform = vmConfig.platform as! Darwin
if randomSerial, let oldPlatform = vmConfig.platform as? Darwin {
vmConfig.platform = Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: oldPlatform.hardwareModel)
}
#endif
@@ -92,12 +89,24 @@ struct Set: AsyncParsableCommand {
extension VMDisplayConfig: ExpressibleByArgument {
public init(argument: String) {
var argument = argument
var unit: Unit? = nil
if argument.hasSuffix(Unit.pixel.rawValue) {
argument = String(argument.dropLast(Unit.pixel.rawValue.count))
unit = Unit.pixel
} else if argument.hasSuffix(Unit.point.rawValue) {
argument = String(argument.dropLast(Unit.point.rawValue.count))
unit = Unit.point
}
let parts = argument.components(separatedBy: "x").map {
Int($0) ?? 0
}
self = VMDisplayConfig(
width: parts[safe: 0] ?? 0,
height: parts[safe: 1] ?? 0
height: parts[safe: 1] ?? 0,
unit: unit,
)
}
}
+22 -1
View File
@@ -9,7 +9,8 @@ struct Config {
var tartHomeDir: URL
if let customTartHome = ProcessInfo.processInfo.environment["TART_HOME"] {
tartHomeDir = URL(fileURLWithPath: customTartHome)
tartHomeDir = URL(fileURLWithPath: customTartHome, isDirectory: true)
try Self.validateTartHome(url: tartHomeDir)
} else {
tartHomeDir = FileManager.default
.homeDirectoryForCurrentUser
@@ -49,4 +50,24 @@ struct Config {
static func jsonDecoder() -> JSONDecoder {
JSONDecoder()
}
private static func validateTartHome(url: URL) throws {
let urlComponents = url.pathComponents
let descendingURLs = urlComponents.indices.map { i in
URL(fileURLWithPath: urlComponents[0...i].joined(separator: "/"))
}
for descendingURL in descendingURLs {
if FileManager.default.fileExists(atPath: descendingURL.path) {
continue
}
do {
try FileManager.default.createDirectory(at: descendingURL, withIntermediateDirectories: false)
} catch {
throw RuntimeError.Generic("TART_HOME is invalid: \(descendingURL.path) does not exist, yet we can't create it: \(error.localizedDescription)")
}
}
}
}
+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 {
var userFriendlyName: String { get }
func retrieve(host: String) throws -> (String, String)?
func store(host: String, user: String, password: String) throws
}
@@ -1,6 +1,8 @@
import Foundation
class DockerConfigCredentialsProvider: CredentialsProvider {
let userFriendlyName = "Docker configuration credentials provider"
func retrieve(host: String) throws -> (String, String)? {
let dockerConfigURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json")
if !FileManager.default.fileExists(atPath: dockerConfigURL.path) {
@@ -36,12 +38,17 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
process.launch()
inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!)
do {
try inPipe.fileHandleForWriting.write(contentsOf: "\(host)\n".data(using: .utf8)!)
} catch {
throw CredentialsProviderError.Failed(message: "Failed to write host to Docker helper!")
}
inPipe.fileHandleForWriting.closeFile()
let outputData = try outPipe.fileHandleForReading.readToEnd()
process.waitUntilExit()
let outputData = try outPipe.fileHandleForReading.readToEnd()
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
if let outputData = outputData {
print(String(decoding: outputData, as: UTF8.self))
@@ -1,6 +1,8 @@
import Foundation
class EnvironmentCredentialsProvider: CredentialsProvider {
let userFriendlyName = "environment variable credentials provider"
func retrieve(host: String) throws -> (String, String)? {
if let tartRegistryHostname = ProcessInfo.processInfo.environment["TART_REGISTRY_HOSTNAME"],
tartRegistryHostname != host {
@@ -1,6 +1,8 @@
import Foundation
class KeychainCredentialsProvider: CredentialsProvider {
let userFriendlyName = "Keychain credentials provider"
func retrieve(host: String) throws -> (String, String)? {
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
@@ -6,6 +6,8 @@ enum StdinCredentialsError: Error {
}
class StdinCredentials {
let userFriendlyName = "standard input credentials provider"
static func retrieve() throws -> (String, String) {
let user = try readStdinCredential(name: "username", prompt: "User: ", isSensitive: false)
let password = try readStdinCredential(name: "password", prompt: "Password: ", isSensitive: true)
@@ -13,7 +15,7 @@ class StdinCredentials {
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 */)
guard let rawCredential = readpassphrase(prompt, &buf, buf.count, isSensitive ? RPP_ECHO_OFF : RPP_ECHO_ON) else {
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 ""
}
}
+81 -64
View File
@@ -1,69 +1,6 @@
import Foundation
import AsyncAlgorithms
fileprivate let urlSession = createURLSession()
class DownloadDelegate: NSObject, URLSessionTaskDelegate {
let progress: Progress
init(_ progress: Progress) throws {
self.progress = progress
}
func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) {
self.progress.addChild(task.progress, withPendingUnitCount: self.progress.totalUnitCount)
}
}
class Fetcher {
static func fetch(_ request: URLRequest, viaFile: Bool = false, progress: Progress? = nil) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
let delegate = progress != nil ? try DownloadDelegate(progress!) : nil
if viaFile {
return try await fetchViaFile(request, delegate: delegate)
}
return try await fetchViaMemory(request, delegate: delegate)
}
private static func fetchViaMemory(_ request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
let dataCh = AsyncThrowingChannel<Data, Error>()
let (data, response) = try await urlSession.data(for: request, delegate: delegate)
Task {
await dataCh.send(data)
dataCh.finish()
}
return (dataCh, response as! HTTPURLResponse)
}
private static func fetchViaFile(_ request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
let dataCh = AsyncThrowingChannel<Data, Error>()
let (fileURL, response) = try await urlSession.download(for: request, delegate: delegate)
// Acquire a handle to the downloaded file and then remove it.
//
// This keeps a working reference to that file, yet we don't
// have to deal with the cleanup any more.
let mappedFile = try Data(contentsOf: fileURL, options: [.alwaysMapped])
try FileManager.default.removeItem(at: fileURL)
Task {
for chunk in (0 ..< mappedFile.count).chunks(ofCount: 64 * 1024 * 1024) {
await dataCh.send(mappedFile.subdata(in: chunk))
}
dataCh.finish()
}
return (dataCh, response as! HTTPURLResponse)
}
}
fileprivate func createURLSession() -> URLSession {
fileprivate var urlSession: URLSession = {
let config = URLSessionConfiguration.default
// Harbor expects a CSRF token to be present if the HTTP client
@@ -77,4 +14,84 @@ fileprivate func createURLSession() -> URLSession {
config.httpShouldSetCookies = false
return URLSession(configuration: config)
}()
class Fetcher {
static func fetch(_ request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingStream<Data, Error>, HTTPURLResponse) {
let task = urlSession.dataTask(with: request)
let delegate = Delegate()
task.delegate = delegate
let stream = AsyncThrowingStream<Data, Error> { continuation in
delegate.streamContinuation = continuation
}
let response = try await withCheckedThrowingContinuation { continuation in
delegate.responseContinuation = continuation
task.resume()
}
return (stream, response as! HTTPURLResponse)
}
}
fileprivate class Delegate: NSObject, URLSessionDataDelegate {
var responseContinuation: CheckedContinuation<URLResponse, Error>?
var streamContinuation: AsyncThrowingStream<Data, Error>.Continuation?
private var buffer: Data = Data()
private let bufferFlushSize = 16 * 1024 * 1024
func urlSession(
_ session: URLSession,
dataTask: URLSessionDataTask,
didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
) {
// Soft-limit for the maximum buffer capacity
let capacity = min(response.expectedContentLength, Int64(bufferFlushSize))
// Pre-initialize buffer as we now know the capacity
buffer = Data(capacity: Int(capacity))
responseContinuation?.resume(returning: response)
responseContinuation = nil
completionHandler(.allow)
}
func urlSession(
_ session: URLSession,
dataTask: URLSessionDataTask,
didReceive data: Data
) {
buffer.append(data)
if buffer.count >= bufferFlushSize {
streamContinuation?.yield(buffer)
buffer.removeAll(keepingCapacity: true)
}
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?
) {
if let error = error {
responseContinuation?.resume(throwing: error)
responseContinuation = nil
streamContinuation?.finish(throwing: error)
streamContinuation = nil
} else {
if !buffer.isEmpty {
streamContinuation?.yield(buffer)
buffer.removeAll(keepingCapacity: true)
}
streamContinuation?.finish()
streamContinuation = nil
}
}
}
+28
View File
@@ -0,0 +1,28 @@
import GRPC
import NIOPosix
/// Connects to a guest agent's gRPC endpoint over a VM's control socket, runs
/// `body` with the resulting channel, and closes the channel afterwards on both
/// the success and error paths.
///
/// The connection uses the process-wide singleton event loop group, which must
/// not be shut down, so there is no group lifecycle to manage here.
func withGuestAgentChannel<T>(
unixDomainSocketPath socketPath: String,
_ body: (GRPCChannel) async throws -> T
) async throws -> T {
let channel = try GRPCChannelPool.with(
target: .unixDomainSocket(socketPath),
transportSecurity: .plaintext,
eventLoopGroup: .singletonMultiThreadedEventLoopGroup,
)
do {
let result = try await body(channel)
try await channel.close().get()
return result
} catch {
try? await channel.close().get()
throw error
}
}
+32 -7
View File
@@ -1,10 +1,25 @@
import Foundation
struct LocalLayerCache {
struct DigestInfo {
let range: Range<Data.Index>
let compressedDigest: String
let uncompressedContentDigest: String?
}
let name: String
let deduplicatedBytes: UInt64
let diskURL: URL
private let mappedDisk: Data
private var digestToRange: [String : Range<Data.Index>] = [:]
private var digestToRange: [String: DigestInfo] = [:]
private var offsetToRange: [UInt64: DigestInfo] = [:]
init?(_ name: String, _ deduplicatedBytes: UInt64, _ diskURL: URL, _ manifest: OCIManifest) throws {
self.name = name
self.deduplicatedBytes = deduplicatedBytes
self.diskURL = diskURL
init?(_ diskURL: URL, _ manifest: OCIManifest) throws {
// mmap(2) the disk that contains the layers from the manifest
self.mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
@@ -16,17 +31,27 @@ struct LocalLayerCache {
return nil
}
self.digestToRange[layer.digest] = Int(offset)..<Int(offset+uncompressedSize)
let info = DigestInfo(
range: Int(offset)..<Int(offset + uncompressedSize),
compressedDigest: layer.digest,
uncompressedContentDigest: layer.uncompressedContentDigest()!
)
self.digestToRange[layer.digest] = info
self.offsetToRange[offset] = info
offset += uncompressedSize
}
}
func find(_ digest: String) -> Data? {
guard let foundRange = self.digestToRange[digest] else {
return nil
func findInfo(digest: String, offsetHint: UInt64) -> DigestInfo? {
// Layers can have the same digests, for example, empty ones. Let's use the offset hint to make a better guess.
if let info = self.offsetToRange[offsetHint], info.compressedDigest == digest {
return info
}
return self.digestToRange[digest]
}
return self.mappedDisk.subdata(in: foundRange)
func subdata(_ range: Range<Data.Index>) -> Data {
return self.mappedDisk.subdata(in: range)
}
}
+12 -2
View File
@@ -4,18 +4,28 @@ public class ProgressObserver: NSObject {
@objc var progressToObserve: Progress
var observation: NSKeyValueObservation?
var lastTimeUpdated = Date.now
private var lastRenderedLine: String?
public init(_ progress: Progress) {
progressToObserve = progress
}
func log(_ renderer: Logger) {
renderer.appendNewLine(ProgressObserver.lineToRender(progressToObserve))
let initialLine = ProgressObserver.lineToRender(progressToObserve)
renderer.appendNewLine(initialLine)
lastRenderedLine = initialLine
observation = observe(\.progressToObserve.fractionCompleted) { progress, _ in
let currentTime = Date.now
if self.progressToObserve.isFinished || currentTime.timeIntervalSince(self.lastTimeUpdated) >= 1.0 {
self.lastTimeUpdated = currentTime
renderer.updateLastLine(ProgressObserver.lineToRender(self.progressToObserve))
let line = ProgressObserver.lineToRender(self.progressToObserve)
// Skip identical renders so non-interactive logs only see new percent values.
if line == self.lastRenderedLine {
return
}
self.lastRenderedLine = line
renderer.updateLastLine(line)
}
}
}
@@ -52,6 +52,11 @@ struct ARPCache {
process.standardInput = FileHandle.nullDevice
try process.run()
guard let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() else {
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
}
process.waitUntilExit()
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
@@ -60,10 +65,6 @@ struct ARPCache {
terminationStatus: process.terminationStatus)
}
guard let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() else {
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
}
self.arpCommandOutput = arpCommandOutput
}
@@ -0,0 +1,28 @@
import Foundation
import Network
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 is GRPCConnectionPoolError {
return nil
}
}
private static func resolveIP(_ controlSocketPath: String) async throws -> IPv4Address? {
try await withGuestAgentChannel(unixDomainSocketPath: controlSocketPath) { channel in
// 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)
}
}
}
+52 -1
View File
@@ -16,7 +16,18 @@ class Softnet: Network {
let vmFD: Int32
init(vmMACAddress: String, extraArguments: [String] = []) throws {
init(vmMACAddress: String, extraArguments: [String] = [], controlFD: Int32? = nil) throws {
var controlFileHandle: FileHandle?
if let controlFD = controlFD {
guard controlFD > STDERR_FILENO else {
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be greater than 2")
}
controlFileHandle = FileHandle(fileDescriptor: controlFD, closeOnDealloc: true)
try Self.validateControlFD(controlFD)
}
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
let ret = socketpair(AF_UNIX, SOCK_DGRAM, 0, fds)
@@ -33,6 +44,44 @@ class Softnet: Network {
process.executableURL = try Self.softnetExecutableURL()
process.arguments = ["--vm-fd", String(STDIN_FILENO), "--vm-mac-address", vmMACAddress] + extraArguments
process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false)
if let controlFileHandle = controlFileHandle {
process.arguments! += ["--control-fd", String(STDOUT_FILENO)]
process.standardOutput = controlFileHandle
}
}
static func validateControlFD(_ fd: Int32) throws {
guard fd > STDERR_FILENO else {
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be greater than 2")
}
var socketType: Int32 = 0
var socketTypeLength = socklen_t(MemoryLayout<Int32>.size)
guard getsockopt(fd, SOL_SOCKET, SO_TYPE, &socketType, &socketTypeLength) == 0 else {
let details = Errno(rawValue: CInt(errno))
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor is not a socket: \(details)")
}
guard socketType == SOCK_STREAM else {
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be a Unix stream socket")
}
var peerAddress = sockaddr_storage()
var peerAddressLength = socklen_t(MemoryLayout<sockaddr_storage>.size)
let result = withUnsafeMutablePointer(to: &peerAddress) { pointer in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
getpeername(fd, $0, &peerAddressLength)
}
}
guard result == 0 else {
let details = Errno(rawValue: CInt(errno))
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor is not connected: \(details)")
}
guard peerAddress.ss_family == sa_family_t(AF_UNIX) else {
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be a Unix stream socket")
}
}
static func softnetExecutableURL() throws -> URL {
@@ -46,6 +95,8 @@ class Softnet: Network {
}
func run(_ sema: AsyncSemaphore) throws {
defer { try? (process.standardOutput as? FileHandle)?.close() }
try process.run()
monitorTask = Task {
+2 -2
View File
@@ -1,6 +1,6 @@
import Foundation
protocol Disk {
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, progress: Progress) async throws -> [OCIManifestLayer]
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache?) async throws
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer]
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache?, deduplicate: Bool) async throws
}
-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, 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) 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 {
disk.write(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()
}
}
+178 -51
View File
@@ -1,44 +1,104 @@
import Foundation
import Compression
import System
import Retry
class DiskV2: Disk {
private static let bufferSizeBytes = 4 * 1024 * 1024
private static let layerLimitBytes = 512 * 1024 * 1024
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, progress: Progress) async throws -> [OCIManifestLayer] {
var pushedLayers: [OCIManifestLayer] = []
// A zero chunk for faster than byte-by-byte comparisons
//
// Assumes that the other Data(...) is equal in size, but it's fine to get a false-negative
// on the last block since it costs only 4 MiB of excess data per 512 MiB layer.
//
// Some simple benchmarks ("sync && sudo purge" command was used to negate the disk caching effects):
// +--------------------------------------+---------------------------------------------------+
// | Operation | time(1) result |
// +--------------------------------------+---------------------------------------------------+
// | Data(...) == zeroChunk | 2.16s user 11.71s system 73% cpu 18.928 total |
// | Data(...).contains(where: {$0 != 0}) | 603.68s user 12.97s system 99% cpu 10:22.85 total |
// +--------------------------------------+---------------------------------------------------+
private static let holeGranularityBytes = 4 * 1024 * 1024
private static let zeroChunk = Data(count: holeGranularityBytes)
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer] {
var pushedLayers: [(index: Int, pushedLayer: OCIManifestLayer)] = []
// Open the disk file
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
// Compress the disk file as multiple individually decompressible streams,
// each equal ``Self.layerLimitBytes`` bytes or less due to LZ4 compression
for data in mappedDisk.chunks(ofCount: layerLimitBytes) {
let compressedData = try (data as NSData).compressed(using: .lz4) as Data
try await withThrowingTaskGroup(of: (Int, OCIManifestLayer).self) { group in
for (index, data) in mappedDisk.chunks(ofCount: layerLimitBytes).enumerated() {
// Respect the concurrency limit
if index >= concurrency {
if let (index, pushedLayer) = try await group.next() {
pushedLayers.append((index, pushedLayer))
}
}
let layerDigest = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb)
// Launch a disk layer pushing task
group.addTask {
let compressedData = try (data as NSData).compressed(using: .lz4) as Data
let compressedDataDigest = Digest.hash(compressedData)
pushedLayers.append(OCIManifestLayer(
mediaType: diskV2MediaType,
size: compressedData.count,
digest: layerDigest,
uncompressedSize: UInt64(data.count),
uncompressedContentDigest: Digest.hash(data)
))
try await retry(maxAttempts: 5) {
if try await !registry.blobExists(compressedDataDigest) {
_ = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb, digest: compressedDataDigest)
}
} recoverFromFailure: { error in
if error is URLError {
print("Error: \(error.localizedDescription)")
print("Attempting to re-try...")
// Update progress using a relative value
progress.completedUnitCount += Int64(data.count)
return .retry
}
return .throw
}
// Update progress using a relative value
progress.completedUnitCount += Int64(data.count)
return (index, OCIManifestLayer(
mediaType: diskV2MediaType,
size: compressedData.count,
digest: compressedDataDigest,
uncompressedSize: UInt64(data.count),
uncompressedContentDigest: Digest.hash(data)
))
}
}
for try await pushedLayer in group {
pushedLayers.append(pushedLayer)
}
}
return pushedLayers
return pushedLayers.sorted {
$0.index < $1.index
}.map {
$0.pushedLayer
}
}
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil) async throws {
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil, deduplicate: Bool = false) async throws {
// Support resumable pulls
let pullResumed = FileManager.default.fileExists(atPath: diskURL.path)
if !pullResumed && !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
throw OCIError.FailedToCreateVmFile
if !pullResumed {
if deduplicate, let localLayerCache = localLayerCache {
// Clone the local layer cache's disk and use it as a base, potentially
// reducing the space usage since some blocks won't be written at all
try FileManager.default.copyItem(at: localLayerCache.diskURL, to: diskURL)
} else {
// Otherwise create an empty disk
if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
throw OCIError.FailedToCreateVmFile
}
}
}
// Calculate the uncompressed disk size
@@ -58,6 +118,15 @@ class DiskV2: Disk {
try disk.truncate(atOffset: uncompressedDiskSize)
try disk.close()
// Determine the file system block size
var st = stat()
if stat(diskURL.path, &st) == -1 {
let details = Errno(rawValue: errno)
throw RuntimeError.PullFailed("failed to stat(2) disk \(diskURL.path): \(details)")
}
let fsBlockSize = UInt64(st.st_blksize)
// Concurrently fetch and decompress layers
try await withThrowingTaskGroup(of: Void.self) { group in
var globalDiskWritingOffset: UInt64 = 0
@@ -82,22 +151,46 @@ class DiskV2: Disk {
// Launch a fetching and decompression task
group.addTask {
// No need to fetch and decompress anything if we've already done so
if try pullResumed && Digest.hash(diskURL, offset: diskWritingOffset, size: uncompressedLayerSize) == uncompressedLayerContentDigest {
// Update the progress
progress.completedUnitCount += Int64(diskLayer.size)
if pullResumed {
// do not check hash in the condition above to make it lazy e.g. only do expensive calculations if needed
if try Digest.hash(diskURL, offset: diskWritingOffset, size: uncompressedLayerSize) == uncompressedLayerContentDigest {
// Update the progress
progress.completedUnitCount += Int64(diskLayer.size)
return
return
}
}
// Open the disk file
// Open the disk file for writing
let disk = try FileHandle(forWritingTo: diskURL)
// Check if we already have this layer contents in the local layer cache
if let localLayerCache = localLayerCache, let data = localLayerCache.find(diskLayer.digest), Digest.hash(data) == uncompressedLayerContentDigest {
// Fulfil the layer contents from the local blob cache
_ = try zeroSkippingWrite(disk, diskWritingOffset, data)
// Also open the disk file for reading and verifying
// its contents in case the local layer cache is used
let rdisk: FileHandle? = if deduplicate && localLayerCache != nil {
try FileHandle(forReadingFrom: diskURL)
} else {
nil
}
// Check if we already have this layer contents in the local layer cache,
// or perhaps even on the cloned disk (when the deduplication is enabled)
if let localLayerCache = localLayerCache,
let localLayerInfo = localLayerCache.findInfo(digest: diskLayer.digest, offsetHint: diskWritingOffset),
localLayerInfo.uncompressedContentDigest == uncompressedLayerContentDigest {
if deduplicate && localLayerInfo.range.lowerBound == diskWritingOffset {
// Do nothing, because the data is already on the disk that we've inherited from
} else {
// Fulfil the layer contents from the local blob cache
let data = localLayerCache.subdata(localLayerInfo.range)
_ = try zeroSkippingWrite(disk, rdisk, fsBlockSize, diskWritingOffset, data)
}
try disk.close()
if let rdisk = rdisk {
try rdisk.close()
}
// Update the progress
progress.completedUnitCount += Int64(diskLayer.size)
@@ -112,19 +205,38 @@ class DiskV2: Disk {
return
}
diskWritingOffset = try zeroSkippingWrite(disk, diskWritingOffset, data)
diskWritingOffset = try zeroSkippingWrite(disk, rdisk, fsBlockSize, diskWritingOffset, data)
}
try await registry.pullBlob(diskLayer.digest) { data in
try filter.write(data)
var rangeStart: Int64 = 0
// Update the progress
progress.completedUnitCount += Int64(data.count)
try await retry(maxAttempts: 5) {
try await registry.pullBlob(diskLayer.digest, rangeStart: rangeStart) { data in
try filter.write(data)
// Update the progress
progress.completedUnitCount += Int64(data.count)
// Update the current range start
rangeStart += Int64(data.count)
}
} recoverFromFailure: { error in
if error is URLError {
print("Error pulling disk layer \(index + 1): \"\(error.localizedDescription)\", attempting to re-try...")
return .retry
}
return .throw
}
try filter.finalize()
try disk.close()
if let rdisk = rdisk {
try rdisk.close()
}
}
globalDiskWritingOffset += uncompressedLayerSize
@@ -132,30 +244,45 @@ class DiskV2: Disk {
}
}
private static func zeroSkippingWrite(_ disk: FileHandle, _ offset: UInt64, _ data: Data) throws -> UInt64 {
let holeGranularityBytes = 64 * 1024
// A zero chunk for faster than byte-by-byte comparisons
//
// Assumes that the other Data(...) is equal in size, but it's fine to get a false-negative
// on the last block since it costs only 64 KiB of excess data per 500 MB layer.
//
// Some simple benchmarks ("sync && sudo purge" command was used to negate the disk caching effects):
// +--------------------------------------+---------------------------------------------------+
// | Operation | time(1) result |
// +--------------------------------------+---------------------------------------------------+
// | Data(...) == zeroChunk | 2.16s user 11.71s system 73% cpu 18.928 total |
// | Data(...).contains(where: {$0 != 0}) | 603.68s user 12.97s system 99% cpu 10:22.85 total |
// +--------------------------------------+---------------------------------------------------+
let zeroChunk = Data(count: holeGranularityBytes)
private static func zeroSkippingWrite(_ disk: FileHandle, _ rdisk: FileHandle?, _ fsBlockSize: UInt64, _ offset: UInt64, _ data: Data) throws -> UInt64 {
var offset = offset
for chunk in data.chunks(ofCount: holeGranularityBytes) {
// Only write chunks that are not zero
// If the local layer cache is used, only write chunks that differ
// since the base disk can contain anything at any position
if let rdisk = rdisk {
// F_PUNCHHOLE requires the holes to be aligned to file system block boundaries
let isHoleAligned = (offset % fsBlockSize) == 0 && (UInt64(chunk.count) % fsBlockSize) == 0
if isHoleAligned && chunk == zeroChunk {
var arg = fpunchhole_t(fp_flags: 0, reserved: 0, fp_offset: off_t(offset), fp_length: off_t(chunk.count))
if fcntl(disk.fileDescriptor, F_PUNCHHOLE, &arg) == -1 {
let details = Errno(rawValue: errno)
throw RuntimeError.PullFailed("failed to punch hole: \(details)")
}
} else {
try rdisk.seek(toOffset: offset)
let actualContentsOnDisk = try rdisk.read(upToCount: chunk.count)
if chunk != actualContentsOnDisk {
try disk.seek(toOffset: offset)
try disk.write(contentsOf: chunk)
}
}
offset += UInt64(chunk.count)
continue
}
// Otherwise, only write chunks that are not zero
// since the base disk is created from scratch and
// is zeroed via truncate(2)
if chunk != zeroChunk {
try disk.seek(toOffset: offset)
disk.write(chunk)
try disk.write(contentsOf: chunk)
}
offset += UInt64(chunk.count)
+8 -1
View File
@@ -6,7 +6,6 @@ let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
// Layer media types
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 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 uploadTimeAnnotation = "org.cirruslabs.tart.upload-time"
// Manifest labels
let diskFormatLabel = "org.cirruslabs.tart.disk.format"
// Layer annotations
let uncompressedSizeAnnotation = "org.cirruslabs.tart.uncompressed-size"
let uncompressedContentDigestAnnotation = "org.cirruslabs.tart.uncompressed-content-digest"
@@ -66,6 +68,11 @@ struct OCIManifest: Codable, Equatable {
struct OCIConfig: Codable {
var architecture: Architecture = .arm64
var os: OS = .darwin
var config: ConfigContainer?
struct ConfigContainer: Codable {
var Labels: [String: String]?
}
func toJSON() throws -> Data {
try Config.jsonEncoder().encode(self)
@@ -1,4 +1,4 @@
// Generated from java-escape by ANTLR 4.11.1
// Generated from Reference.g4 by ANTLR 4.13.2
import Antlr4
@@ -1,4 +1,4 @@
// Generated from java-escape by ANTLR 4.11.1
// Generated from Reference.g4 by ANTLR 4.13.2
import Antlr4
open class ReferenceLexer: Lexer {
@@ -49,7 +49,7 @@ open class ReferenceLexer: Lexer {
public
required init(_ input: CharStream) {
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION)
RuntimeMetaData.checkVersion("4.13.2", RuntimeMetaData.VERSION)
super.init(input)
_interp = LexerATNSimulator(self, ReferenceLexer._ATN, ReferenceLexer._decisionToDFA, ReferenceLexer._sharedContextCache)
}
@@ -1,4 +1,4 @@
// Generated from java-escape by ANTLR 4.11.1
// Generated from Reference.g4 by ANTLR 4.13.2
import Antlr4
/**
@@ -1,4 +1,4 @@
// Generated from java-escape by ANTLR 4.11.1
// Generated from Reference.g4 by ANTLR 4.13.2
import Antlr4
open class ReferenceParser: Parser {
@@ -41,7 +41,7 @@ open class ReferenceParser: Parser {
static let VOCABULARY = Vocabulary(_LITERAL_NAMES, _SYMBOLIC_NAMES)
override open
func getGrammarFileName() -> String { return "java-escape" }
func getGrammarFileName() -> String { return "Reference.g4" }
override open
func getRuleNames() -> [String] { return ReferenceParser.ruleNames }
@@ -60,7 +60,7 @@ open class ReferenceParser: Parser {
override public
init(_ input:TokenStream) throws {
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION)
RuntimeMetaData.checkVersion("4.13.2", RuntimeMetaData.VERSION)
try super.init(input)
_interp = ParserATNSimulator(self,ReferenceParser._ATN,ReferenceParser._decisionToDFA, ReferenceParser._sharedContextCache)
}
@@ -460,7 +460,7 @@ open class ReferenceParser: Parser {
setState(63)
try _errHandler.sync(self)
_la = try _input.LA(1)
if ((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0) {
if (((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0)) {
setState(62)
try separator()
@@ -611,7 +611,7 @@ open class ReferenceParser: Parser {
setState(84)
try _errHandler.sync(self)
_la = try _input.LA(1)
while ((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0) {
while (((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0)) {
setState(79)
try separator()
setState(80)
@@ -664,7 +664,7 @@ open class ReferenceParser: Parser {
try enterOuterAlt(_localctx, 1)
setState(87)
_la = try _input.LA(1)
if (!((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0)) {
if (!(((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0))) {
try _errHandler.recoverInline(self)
}
else {
+68 -24
View File
@@ -1,6 +1,5 @@
import Foundation
import Algorithms
import AsyncAlgorithms
enum RegistryError: Error {
case UnexpectedHTTPStatusCode(when: String, code: Int, details: String = "")
@@ -10,6 +9,7 @@ enum RegistryError: Error {
}
enum HTTPMethod: String {
case HEAD = "HEAD"
case GET = "GET"
case POST = "POST"
case PUT = "PUT"
@@ -20,21 +20,35 @@ enum HTTPCode: Int {
case Ok = 200
case Created = 201
case Accepted = 202
case PartialContent = 206
case Unauthorized = 401
case NotFound = 404
}
extension Data {
func asText() -> String {
String(decoding: self, as: UTF8.self)
}
func asTextPreview(limit: Int = 1000) -> String {
guard count > limit else {
return asText()
}
return "\(asText().prefix(limit))..."
}
}
extension AsyncThrowingChannel<Data, Error> {
func asData() async throws -> Data {
extension AsyncThrowingStream<Data, Error> {
func asData(limitBytes: Int64? = nil) async throws -> Data {
var result = Data()
for try await chunk in self {
result += chunk
if let limitBytes, result.count > limitBytes {
return result
}
}
return result
@@ -158,7 +172,7 @@ class Registry {
body: manifestJSON)
if response.statusCode != HTTPCode.Created.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing manifest", code: response.statusCode,
details: data.asText())
details: data.asTextPreview())
}
return Digest.hash(manifestJSON)
@@ -169,7 +183,7 @@ class Registry {
headers: ["Accept": ociManifestMediaType])
if response.statusCode != HTTPCode.Ok.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling manifest", code: response.statusCode,
details: data.asText())
details: data.asTextPreview())
}
let manifest = try OCIManifest(fromJSON: data)
@@ -189,19 +203,19 @@ class Registry {
return URLComponents(url: uploadLocation.absolutize(baseURL), resolvingAgainstBaseURL: true)!
}
public func pushBlob(fromData: Data, chunkSizeMb: Int = 0) async throws -> String {
public func pushBlob(fromData: Data, chunkSizeMb: Int = 0, digest: String? = nil) async throws -> String {
// Initiate a blob upload
let (data, postResponse) = try await dataRequest(.POST, endpointURL("\(namespace)/blobs/uploads/"),
headers: ["Content-Length": "0"])
if postResponse.statusCode != HTTPCode.Accepted.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (POST)", code: postResponse.statusCode,
details: data.asText())
details: data.asTextPreview())
}
// Figure out where to upload the blob
var uploadLocation = try uploadLocationFromResponse(postResponse)
let digest = Digest.hash(fromData)
let digest = digest ?? Digest.hash(fromData)
if chunkSizeMb == 0 {
// monolithic upload
@@ -216,7 +230,7 @@ class Registry {
)
if response.statusCode != HTTPCode.Created.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT) to \(uploadLocation)",
code: response.statusCode, details: data.asText())
code: response.statusCode, details: data.asTextPreview())
}
return digest
}
@@ -239,7 +253,7 @@ class Registry {
// always accept both statuses since AWS ECR is not following specification
if response.statusCode != HTTPCode.Created.rawValue && response.statusCode != HTTPCode.Accepted.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "streaming blob to \(uploadLocation)",
code: response.statusCode, details: data.asText())
code: response.statusCode, details: data.asTextPreview())
}
uploadedBytes += chunk.count
// Update location for the next chunk
@@ -249,10 +263,35 @@ class Registry {
return digest
}
public func pullBlob(_ digest: String, handler: (Data) async throws -> Void) async throws {
let (channel, response) = try await channelRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"), viaFile: true)
if response.statusCode != HTTPCode.Ok.rawValue {
let body = try await channel.asData().asText()
public func blobExists(_ digest: String) async throws -> Bool {
let (data, response) = try await dataRequest(.HEAD, endpointURL("\(namespace)/blobs/\(digest)"))
switch response.statusCode {
case HTTPCode.Ok.rawValue:
return true
case HTTPCode.NotFound.rawValue:
return false
default:
throw RegistryError.UnexpectedHTTPStatusCode(when: "checking blob", code: response.statusCode, details: data.asTextPreview())
}
}
public func pullBlob(_ digest: String, rangeStart: Int64 = 0, handler: (Data) async throws -> Void) async throws {
var expectedStatusCode = HTTPCode.Ok
var headers: [String: String] = [:]
// Send Range header and expect HTTP 206 in return
//
// However, do not send Range header at all when rangeStart is 0,
// because it makes no sense and we might get HTTP 200 in return
if rangeStart != 0 {
expectedStatusCode = HTTPCode.PartialContent
headers["Range"] = "bytes=\(rangeStart)-"
}
let (channel, response) = try await channelRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"), headers: headers, viaFile: true)
if response.statusCode != expectedStatusCode.rawValue {
let body = try await channel.asData(limitBytes: 4096).asTextPreview()
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling blob", code: response.statusCode,
details: body)
}
@@ -292,7 +331,7 @@ class Registry {
body: Data? = nil,
doAuth: Bool = true,
viaFile: Bool = false
) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
) async throws -> (AsyncThrowingStream<Data, Error>, HTTPURLResponse) {
var urlComponents = urlComponents
if urlComponents.queryItems == nil && !parameters.isEmpty {
@@ -312,12 +351,11 @@ class Registry {
request.httpBody = body
}
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile, doAuth: doAuth)
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
_ = try await channel.asData()
try await auth(response: response)
(channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
(channel, response) = try await authAwareRequest(request: request, viaFile: viaFile, doAuth: doAuth)
}
return (channel, response)
@@ -377,7 +415,7 @@ class Registry {
let (data, response) = try await dataRequest(.GET, authenticateURL, headers: headers, doAuth: false)
if response.statusCode != HTTPCode.Ok.rawValue {
throw RegistryError.AuthFailed(why: "received unexpected HTTP status code \(response.statusCode) "
+ "while retrieving an authentication token", details: data.asText())
+ "while retrieving an authentication token", details: data.asTextPreview())
}
await authenticationKeeper.set(try TokenResponse.parse(fromData: data))
@@ -391,18 +429,24 @@ class Registry {
}
for provider in credentialsProviders {
if let (user, password) = try provider.retrieve(host: host) {
return (user, password)
do {
if let (user, password) = try provider.retrieve(host: host) {
return (user, password)
}
} catch (let e) {
print("Failed to retrieve credentials using \(provider.userFriendlyName), authentication may fail: \(e)")
}
}
return nil
}
private func authAwareRequest(request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
private func authAwareRequest(request: URLRequest, viaFile: Bool = false, doAuth: Bool) async throws -> (AsyncThrowingStream<Data, Error>, HTTPURLResponse) {
var request = request
if let (name, value) = await authenticationKeeper.header() {
request.addValue(value, forHTTPHeaderField: name)
if doAuth {
if let (name, value) = await authenticationKeeper.header() {
request.addValue(value, forHTTPHeaderField: name)
}
}
request.setValue("Tart/\(CI.version) (\(DeviceInfo.os); \(DeviceInfo.model))",
+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))
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ class PIDLock {
if fd == -1 {
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.PIDLockFailed("failed to open lock file \(url): \(details)")
throw RuntimeError.PIDLockMissing("failed to open lock file \(url): \(details)")
}
}
+30 -7
View File
@@ -8,7 +8,7 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
#if arch(arm64)
struct Darwin: Platform {
struct Darwin: PlatformSuspendable {
var ecid: VZMacMachineIdentifier
var hardwareModel: VZMacHardwareModel
@@ -38,7 +38,7 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
}
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
throw UnsupportedHostOSError()
}
self.hardwareModel = hardwareModel
}
@@ -58,7 +58,11 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
VZMacOSBootLoader()
}
func platform(nvramURL: URL) throws -> VZPlatformConfiguration {
func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration {
if needsNestedVirtualization {
throw RuntimeError.VMConfigurationError("macOS virtual machines do not support nested virtualization")
}
let result = VZMacPlatformConfiguration()
result.machineIdentifier = ecid
@@ -78,7 +82,7 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
let result = VZMacGraphicsDeviceConfiguration()
if let hostMainScreen = NSScreen.main {
if (vmConfig.display.unit ?? .point) == .point, let hostMainScreen = NSScreen.main {
let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
result.displays = [
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
@@ -103,18 +107,37 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
func keyboards() -> [VZKeyboardConfiguration] {
if #available(macOS 14, *) {
// Mac keyboard is only supported by guests starting with macOS Ventura
return [VZMacKeyboardConfiguration()]
return [VZUSBKeyboardConfiguration(), VZMacKeyboardConfiguration()]
} else {
return [VZUSBKeyboardConfiguration()]
}
}
func keyboardsSuspendable() -> [VZKeyboardConfiguration] {
if #available(macOS 14, *) {
return [VZMacKeyboardConfiguration()]
} else {
// fallback to the regular configuration
return keyboards()
}
}
func pointingDevices() -> [VZPointingDeviceConfiguration] {
if #available(macOS 13, *) {
// Trackpad is only supported by guests starting with macOS Ventura
[VZUSBScreenCoordinatePointingDeviceConfiguration(), VZMacTrackpadConfiguration()]
}
func pointingDevicesSimplified() -> [VZPointingDeviceConfiguration] {
// Only include the USB pointing device, not the trackpad
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
}
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
if #available(macOS 14, *) {
return [VZMacTrackpadConfiguration()]
} else {
// fallback to the regular configuration
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
return pointingDevices()
}
}
}
+11 -2
View File
@@ -14,8 +14,12 @@ struct Linux: Platform {
return result
}
func platform(nvramURL: URL) throws -> VZPlatformConfiguration {
VZGenericPlatformConfiguration()
func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration {
let config = VZGenericPlatformConfiguration()
if #available(macOS 15, *) {
config.isNestedVirtualizationEnabled = needsNestedVirtualization
}
return config
}
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
@@ -38,4 +42,9 @@ struct Linux: Platform {
func pointingDevices() -> [VZPointingDeviceConfiguration] {
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
}
func pointingDevicesSimplified() -> [VZPointingDeviceConfiguration] {
// Linux doesn't support trackpad, so just return the regular pointing devices
return pointingDevices()
}
}
+7 -1
View File
@@ -3,8 +3,14 @@ import Virtualization
protocol Platform: Codable {
func os() -> OS
func bootLoader(nvramURL: URL) throws -> VZBootLoader
func platform(nvramURL: URL) throws -> VZPlatformConfiguration
func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
func keyboards() -> [VZKeyboardConfiguration]
func pointingDevices() -> [VZPointingDeviceConfiguration]
func pointingDevicesSimplified() -> [VZPointingDeviceConfiguration]
}
protocol PlatformSuspendable: Platform {
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration]
func keyboardsSuspendable() -> [VZKeyboardConfiguration]
}
+148 -73
View File
@@ -1,7 +1,9 @@
import ArgumentParser
import Darwin
import Foundation
import Sentry
import OpenTelemetryApi
import OpenTelemetrySdk
import OpenTelemetryProtocolExporterHttp
@main
struct Root: AsyncParsableCommand {
@@ -18,6 +20,7 @@ struct Root: AsyncParsableCommand {
Login.self,
Logout.self,
IP.self,
Exec.self,
Pull.self,
Push.self,
Import.self,
@@ -29,95 +32,160 @@ struct Root: AsyncParsableCommand {
FQN.self,
])
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)
}
}
}
// Note: main() is intentionally synchronous. Swift's asynchronous main() entry
// point implicitly starts an executor that owns the main thread — and since
// Swift 6.4 that executor is no longer backed by the Dispatch main queue — so
// running an AppKit/SwiftUI run loop nested inside it leaves the main run loop
// unable to drain Tasks or DispatchQueue.main, and a VM started via "tart run"
// never boots. Keeping main() synchronous lets a command that needs the main
// run loop own it at the top level, exactly like a plain SwiftUI app.
public static func main() {
// Add commands that are only available on specific macOS versions
if #available(macOS 14, *) {
configuration.subcommands.append(Suspend.self)
}
// Ensure the default SIGINT handled is disabled,
// otherwise there's a race between two handlers
signal(SIGINT, SIG_IGN);
// Handle cancellation by Ctrl+C ourselves
let task = withUnsafeCurrentTask { $0 }!
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
sigintSrc.setEventHandler {
task.cancel()
}
sigintSrc.activate()
// Ensure the default SIGINT handler is disabled, otherwise there's a race
// between two handlers. We handle cancellation by Ctrl+C ourselves below.
signal(SIGINT, SIG_IGN)
// Set line-buffered output for stdout
setlinebuf(stdout)
// Parse and run command
// Parse the command up-front, synchronously, so we can decide who gets to own
// the main thread before any concurrency is involved.
//
// ParsableCommand isn't Sendable, but we only ever hand it to the single task
// spawned below and never touch it again afterwards, so transferring it into
// that task is safe.
nonisolated(unsafe) let command: ParsableCommand
do {
var command = try parseAsRoot()
// Run garbage-collection before each command (shouldn't take too long)
if type(of: command) != type(of: Pull()) && type(of: command) != type(of: Clone()){
do {
try Config().gc()
} catch {
fputs("Failed to perform garbage collection!\n\(error)\n", stderr)
}
}
if var asyncCommand = command as? AsyncParsableCommand {
try await asyncCommand.run()
} else {
try command.run()
}
command = try parseAsRoot()
} catch {
// Capture the error into Sentry
SentrySDK.capture(error: error)
SentrySDK.flush(timeout: 2.seconds.timeInterval)
exit(withError: error)
}
// Handle a non-ArgumentParser's exception that requires a specific exit code to be set
if let errorWithExitCode = error as? HasExitCode {
fputs("\(error)\n", stderr)
Foundation.exit(errorWithExitCode.exitCode)
if let mainThreadCommand = command as? MainThreadCommand {
// This command drives a run loop on the main thread, so run it right here,
// letting it own the main thread at the top level.
MainActor.assumeIsolated {
runOnMainThread(mainThreadCommand)
}
} else {
// Every other command is asynchronous and doesn't touch the main thread, so
// drive it from a detached task and let the Dispatch main queue keep the
// process alive until the command exits.
let task = Task.detached {
await runInBackground(command)
}
// Handle any other exception, including ArgumentParser's ones
exit(withError: error)
// Handle cancellation by Ctrl+C ourselves
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
sigintSrc.setEventHandler {
task.cancel()
}
sigintSrc.activate()
dispatchMain()
}
}
private static func parseCirrusSentryTag(_ tag: String.SubSequence) -> (String, String)? {
@MainActor
private static func runOnMainThread(_ command: MainThreadCommand) {
let span = startCommandSpan(for: command)
runGarbageCollection(for: command)
do {
// Enters the run loop and only returns once the command exits via
// Foundation.exit(), so the lines below are a best-effort fallback.
try command.runOnMainThread()
} catch {
handleError(error, span: span)
}
span.end()
OTel.shared.flush()
Foundation.exit(0)
}
private static func runInBackground(_ command: ParsableCommand) async {
let span = startCommandSpan(for: command)
runGarbageCollection(for: command)
do {
if var asyncCommand = command as? AsyncParsableCommand {
try await asyncCommand.run()
} else {
var command = command
try command.run()
}
} catch {
handleError(error, span: span)
}
span.end()
OTel.shared.flush()
Foundation.exit(0)
}
// Create a root span for the command we're about to run.
private static func startCommandSpan(for command: ParsableCommand) -> Span {
let span = OTel.shared.tracer.spanBuilder(spanName: type(of: command)._commandName).startSpan()
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))
}
}
return span
}
// Run garbage-collection before each command (shouldn't take too long).
private static func runGarbageCollection(for command: ParsableCommand) {
if type(of: command) != type(of: Pull()) && type(of: command) != type(of: Clone()) {
do {
try Config().gc()
} catch {
fputs("Failed to perform garbage collection: \(error)\n", stderr)
}
}
}
private static func handleError(_ error: Error, span: Span) -> Never {
// Not an error, just a custom exit code from "tart exec"
if let execCustomExitCodeError = error as? ExecCustomExitCodeError {
span.end()
OTel.shared.flush()
Foundation.exit(execCustomExitCodeError.exitCode)
}
// Capture the error into OpenTelemetry
OpenTelemetry.instance.contextProvider.activeSpan?.recordException(error)
span.end()
// Handle a non-ArgumentParser's exception that requires a specific exit code to be set
if let errorWithExitCode = error as? HasExitCode {
fputs("\(error)\n", stderr)
OTel.shared.flush()
Foundation.exit(errorWithExitCode.exitCode)
}
// Handle any other exception, including ArgumentParser's ones
OTel.shared.flush()
exit(withError: error)
}
private static func splitEnvironmentVariable(_ tag: String.SubSequence) -> (String, String)? {
let splits = tag.split(separator: "=", maxSplits: 1)
if splits.count != 2 {
return nil
@@ -126,3 +194,10 @@ struct Root: AsyncParsableCommand {
return (String(splits[0]), String(splits[1]))
}
}
// A command that drives an AppKit/SwiftUI run loop and therefore has to own the
// main thread at the top level, rather than running inside Swift's asynchronous
// main() executor. See Root.main() for the rationale.
protocol MainThreadCommand: ParsableCommand {
@MainActor func runOnMainThread() throws
}
@@ -5,7 +5,7 @@ fileprivate func normalizeName(_ name: String) -> String {
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
normalizeName(name)
}) ?? []
@@ -15,12 +15,12 @@ func completeMachines(_ arguments: [String]) -> [String] {
return (localVMs + ociVMs)
}
func completeLocalMachines(_ arguments: [String]) -> [String] {
func completeLocalMachines(_ arguments: [String], _ argumentIdx: Int, _ argumentPrefix: String) -> [String] {
let localVMs = (try? VMStorageLocal().list()) ?? []
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()) ?? []
return localVMs
.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)
}
}
+4 -1
View File
@@ -1,4 +1,5 @@
import Foundation
import System
extension URL {
func accessDate() throws -> Date {
@@ -13,7 +14,9 @@ extension URL {
let times = [accessDate.asTimeval(), modificationDate.asTimeval()]
let ret = utimes(path, times)
if ret != 0 {
throw RuntimeError.FailedToUpdateAccessDate("utimes(2) failed: \(ret.explanation())")
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.FailedToUpdateAccessDate("utimes(2) failed: \(details)")
}
}
}
+25
View File
@@ -1,4 +1,5 @@
import Foundation
import XAttr
extension URL: Prunable {
var url: URL {
@@ -13,7 +14,31 @@ extension URL: Prunable {
try resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize!
}
func deduplicatedSizeBytes() throws -> Int {
let values = try resourceValues(forKeys: [.totalFileAllocatedSizeKey, .mayShareFileContentKey])
// make sure the file's origin file is there and duplication works
if values.mayShareFileContent == true {
return Int(deduplicatedBytes())
}
return 0
}
func sizeBytes() throws -> Int {
try resourceValues(forKeys: [.totalFileSizeKey]).totalFileSize!
}
func setDeduplicatedBytes(_ size: UInt64) {
let data = "\(size)".data(using: .utf8)!
try! self.setExtendedAttribute(name: "run.tart.deduplicated-bytes", value: data)
}
func deduplicatedBytes() -> UInt64 {
guard let data = try? self.extendedAttributeValue(forName: "run.tart.deduplicated-bytes") else {
return 0
}
if let strValue = String(data: data, encoding: .utf8) {
return UInt64(strValue) ?? 0
}
return 0
}
}
+18
View File
@@ -1,5 +1,23 @@
import Foundation
// A fire-and-forget task that reports any thrown error to stderr. An unstructured
// Task spawned from a synchronous context (a signal handler, a SwiftUI action) has
// no parent to propagate its error to, so we report it here instead of dropping it.
struct ErrorReportingTask {
let task: Task<Void, Never>
@discardableResult
init(_ context: String, operation: @escaping @Sendable () async throws -> Void) {
task = Task {
do {
try await operation()
} catch {
fputs("\(context): \(error)\n", stderr)
}
}
}
}
extension Collection {
subscript (safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
+108 -49
View File
@@ -1,6 +1,5 @@
import Foundation
import Virtualization
import AsyncAlgorithms
import Semaphore
struct UnsupportedRestoreImageError: Error {
@@ -47,8 +46,14 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
serialPorts: [VZSerialPortConfiguration] = [],
suspendable: Bool = false,
nested: Bool = false,
audio: Bool = true,
clipboard: Bool = true
clipboard: Bool = true,
sync: VZDiskImageSynchronizationMode = .full,
caching: VZDiskImageCachingMode? = nil,
noTrackpad: Bool = false,
noPointer: Bool = false,
noKeyboard: Bool = false
) throws {
name = vmDir.name
config = try VMConfig.init(fromURL: vmDir.configURL)
@@ -65,8 +70,14 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
directorySharingDevices: directorySharingDevices,
serialPorts: serialPorts,
suspendable: suspendable,
nested: nested,
audio: audio,
clipboard: clipboard
clipboard: clipboard,
sync: sync,
caching: caching,
noTrackpad: noTrackpad,
noPointer: noPointer,
noKeyboard: noKeyboard
)
virtualMachine = VZVirtualMachine(configuration: configuration)
@@ -94,16 +105,13 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Download the IPSW
defaultLogger.appendNewLine("Fetching \(remoteURL.lastPathComponent)...")
let downloadProgress = Progress(totalUnitCount: 100)
ProgressObserver(downloadProgress).log(defaultLogger)
let request = URLRequest(url: remoteURL)
let (channel, response) = try await Fetcher.fetch(request, viaFile: true, progress: downloadProgress)
let (channel, response) = try await Fetcher.fetch(request, viaFile: true)
let temporaryLocation = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString + ".ipsw")
defaultLogger.appendNewLine("Computing digest for \(temporaryLocation.path)...")
let digestProgress = Progress(totalUnitCount: response.expectedContentLength)
ProgressObserver(digestProgress).log(defaultLogger)
let progress = Progress(totalUnitCount: response.expectedContentLength)
ProgressObserver(progress).log(defaultLogger)
FileManager.default.createFile(atPath: temporaryLocation.path, contents: nil)
let lock = try FileLock(lockURL: temporaryLocation)
@@ -113,10 +121,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
let digest = Digest()
for try await chunk in channel {
let chunkAsData = Data(chunk)
fileHandle.write(chunkAsData)
digest.update(chunkAsData)
digestProgress.completedUnitCount += Int64(chunk.count)
try fileHandle.write(contentsOf: chunk)
digest.update(chunk)
progress.completedUnitCount += Int64(chunk.count)
}
try fileHandle.close()
@@ -140,6 +147,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
vmDir: VMDirectory,
ipswURL: URL,
diskSizeGB: UInt16,
diskFormat: DiskImageFormat = .raw,
network: Network = NetworkShared(),
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
@@ -172,14 +180,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
_ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
// Create disk
try vmDir.resizeDisk(diskSizeGB)
try vmDir.resizeDisk(diskSizeGB, format: diskFormat)
name = vmDir.name
// Create config
config = VMConfig(
platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
cpuCountMin: requirements.minimumSupportedCPUCount,
memorySizeMin: requirements.minimumSupportedMemorySize
memorySizeMin: requirements.minimumSupportedMemorySize,
diskFormat: diskFormat
)
// allocate at least 4 CPUs because otherwise VMs are frequently freezing
try config.setCPU(cpuCount: max(4, requirements.minimumSupportedCPUCount))
@@ -221,30 +230,43 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
#endif
@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
_ = try VZEFIVariableStore(creatingVariableStoreAt: vmDir.nvramURL)
// Create disk
try vmDir.resizeDisk(diskSizeGB)
try vmDir.resizeDisk(diskSizeGB, format: diskFormat)
// 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)
return try VM(vmDir: vmDir)
}
func start(recovery: Bool, resume shouldResume: Bool) async throws {
func start(recovery: Bool, resume shouldResume: Bool, provisioning: GuestProvisioningOptions? = nil) async throws {
try network.run(sema)
if shouldResume {
try await resume()
} else {
try await start(recovery)
try await start(recovery, provisioning: provisioning)
}
}
@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 {
do {
try await sema.waitUnlessCancelled()
@@ -264,10 +286,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
}
@MainActor
private func start(_ recovery: Bool) async throws {
private func start(_ recovery: Bool, provisioning: GuestProvisioningOptions? = nil) async throws {
#if arch(arm64)
let startOptions = VZMacOSVirtualMachineStartOptions()
startOptions.startUpFromMacOSRecovery = recovery
#if compiler(>=6.4)
if let provisioning = provisioning, #available(macOS 27, *) {
try startOptions.setGuestProvisioning(provisioning.toVZMacGuestProvisioningOptions())
}
#endif
try await virtualMachine.start(options: startOptions)
#else
try await virtualMachine.start()
@@ -293,8 +320,14 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
directorySharingDevices: [VZDirectorySharingDeviceConfiguration],
serialPorts: [VZSerialPortConfiguration],
suspendable: Bool = false,
nested: Bool = false,
audio: Bool = true,
clipboard: Bool = true
clipboard: Bool = true,
sync: VZDiskImageSynchronizationMode = .full,
caching: VZDiskImageCachingMode? = nil,
noTrackpad: Bool = false,
noPointer: Bool = false,
noKeyboard: Bool = false
) throws -> VZVirtualMachineConfiguration {
let configuration = VZVirtualMachineConfiguration()
@@ -306,25 +339,49 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
configuration.memorySize = vmConfig.memorySize
// Platform
configuration.platform = try vmConfig.platform.platform(nvramURL: nvramURL)
configuration.platform = try vmConfig.platform.platform(nvramURL: nvramURL, needsNestedVirtualization: nested)
// Display
configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
// Audio
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
if audio && !suspendable {
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration()
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
let outputAudioStreamConfiguration = VZVirtioSoundDeviceOutputStreamConfiguration()
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
outputAudioStreamConfiguration.sink = VZHostAudioOutputStreamSink()
soundDeviceConfiguration.streams = [inputAudioStreamConfiguration, outputAudioStreamConfiguration]
configuration.audioDevices = [soundDeviceConfiguration]
} else {
// just a null speaker
soundDeviceConfiguration.streams = [VZVirtioSoundDeviceOutputStreamConfiguration()]
}
configuration.audioDevices = [soundDeviceConfiguration]
// Keyboard and mouse
configuration.keyboards = vmConfig.platform.keyboards()
configuration.pointingDevices = vmConfig.platform.pointingDevices()
if suspendable, let platformSuspendable = vmConfig.platform.self as? PlatformSuspendable {
configuration.keyboards = platformSuspendable.keyboardsSuspendable()
configuration.pointingDevices = platformSuspendable.pointingDevicesSuspendable()
} else {
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
configuration.networkDevices = network.attachments().map {
@@ -335,28 +392,29 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
}
// Clipboard sharing via Spice agent
if clipboard && vmConfig.os == .linux {
if clipboard {
let spiceAgentConsoleDevice = VZVirtioConsoleDeviceConfiguration()
let spiceAgentPort = VZVirtioConsolePortConfiguration()
spiceAgentPort.name = VZSpiceAgentPortAttachment.spiceAgentPortName
spiceAgentPort.attachment = VZSpiceAgentPortAttachment()
let spiceAgentPortAttachment = VZSpiceAgentPortAttachment()
spiceAgentPortAttachment.sharesClipboard = true
spiceAgentPort.attachment = spiceAgentPortAttachment
spiceAgentConsoleDevice.ports[0] = spiceAgentPort
configuration.consoleDevices.append(spiceAgentConsoleDevice)
}
// Storage
let attachment: VZDiskImageStorageDeviceAttachment = vmConfig.os == .linux ?
// Use "cached" caching mode for virtio drive to prevent fs corruption on linux
try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false, cachingMode: .cached, synchronizationMode: .full) :
try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)
let attachment = try VZDiskImageStorageDeviceAttachment(
url: diskURL,
readOnly: false,
// When not specified, use "cached" caching mode for Linux VMs to prevent file-system corruption[1]
//
// [1]: https://github.com/cirruslabs/tart/pull/675
cachingMode: caching ?? (vmConfig.os == .linux ? .cached : .automatic),
synchronizationMode: sync
)
var device: VZStorageDeviceConfiguration
if #available(macOS 14, *), vmConfig.os == .linux {
device = VZNVMExpressControllerDeviceConfiguration(attachment: attachment)
} else {
device = VZVirtioBlockDeviceConfiguration(attachment: attachment)
}
var devices: [VZStorageDeviceConfiguration] = [device]
var devices: [VZStorageDeviceConfiguration] = [VZVirtioBlockDeviceConfiguration(attachment: attachment)]
devices.append(contentsOf: additionalStorageDevices)
configuration.storageDevices = devices
@@ -375,15 +433,16 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
//
// A dummy console device useful for implementing
// host feature checks in the guest agent software.
if !suspendable {
let consolePort = VZVirtioConsolePortConfiguration()
consolePort.name = "tart-version-\(CI.version)"
let consolePort = VZVirtioConsolePortConfiguration()
consolePort.name = "tart-version-\(CI.version)"
let consoleDevice = VZVirtioConsoleDeviceConfiguration()
consoleDevice.ports[0] = consolePort
let consoleDevice = VZVirtioConsoleDeviceConfiguration()
consoleDevice.ports[0] = consolePort
configuration.consoleDevices.append(consoleDevice)
}
configuration.consoleDevices.append(consoleDevice)
// Socket device
configuration.socketDevices = [VZVirtioSocketDeviceConfiguration()]
try configuration.validate()
+26 -3
View File
@@ -24,20 +24,32 @@ enum CodingKeys: String, CodingKey {
case memorySize
case macAddress
case display
case displayRefit
case diskFormat
// macOS-specific keys
case ecid
case hardwareModel
}
struct VMDisplayConfig: Codable {
struct VMDisplayConfig: Codable, Equatable {
enum Unit: String, Codable {
case point = "pt"
case pixel = "px"
}
var width: Int = 1024
var height: Int = 768
var unit: Unit?
}
extension VMDisplayConfig: CustomStringConvertible {
var description: String {
"\(width)x\(height)"
if let unit {
"\(width)x\(height)\(unit.rawValue)"
} else {
"\(width)x\(height)"
}
}
}
@@ -52,12 +64,15 @@ struct VMConfig: Codable {
private(set) var memorySize: UInt64
var macAddress: VZMACAddress
var display: VMDisplayConfig = VMDisplayConfig()
var displayRefit: Bool?
var diskFormat: DiskImageFormat = .raw
init(
platform: Platform,
cpuCountMin: Int,
memorySizeMin: UInt64,
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered(),
diskFormat: DiskImageFormat = .raw
) {
self.os = platform.os()
self.arch = CurrentArchitecture()
@@ -65,6 +80,7 @@ struct VMConfig: Codable {
self.macAddress = macAddress
self.cpuCountMin = cpuCountMin
self.memorySizeMin = memorySizeMin
self.diskFormat = diskFormat
cpuCount = cpuCountMin
memorySize = memorySizeMin
}
@@ -121,6 +137,9 @@ struct VMConfig: Codable {
self.macAddress = macAddress
display = try container.decodeIfPresent(VMDisplayConfig.self, forKey: .display) ?? VMDisplayConfig()
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 {
@@ -136,6 +155,10 @@ struct VMConfig: Codable {
try container.encode(memorySize, forKey: .memorySize)
try container.encode(macAddress.string, forKey: .macAddress)
try container.encode(display, forKey: .display)
if let displayRefit = displayRefit {
try container.encode(displayRefit, forKey: .displayRefit)
}
try container.encode(diskFormat.rawValue, forKey: .diskFormat)
}
mutating func setCPU(cpuCount: Int) throws {
+33 -31
View File
@@ -1,6 +1,8 @@
import Compression
import Foundation
import Sentry
import OpenTelemetryApi
let legacyDiskV1MediaType = "application/vnd.cirruslabs.tart.disk.v1"
enum OCIError: Error {
case ShouldBeExactlyOneLayer
@@ -11,10 +13,7 @@ enum OCIError: Error {
}
extension VMDirectory {
private static let bufferSizeBytes = 64 * 1024 * 1024
private static let layerLimitBytes = 500 * 1000 * 1000
func pullFromRegistry(registry: Registry, manifest: OCIManifest, concurrency: UInt, localLayerCache: LocalLayerCache?) async throws {
func pullFromRegistry(registry: Registry, manifest: OCIManifest, concurrency: UInt, localLayerCache: LocalLayerCache?, deduplicate: Bool) async throws {
// Pull VM's config file layer and re-serialize it into a config file
let configLayers = manifest.layers.filter {
$0.mediaType == configMediaType
@@ -27,26 +26,25 @@ extension VMDirectory {
}
let configFile = try FileHandle(forWritingTo: configURL)
try await registry.pullBlob(configLayers.first!.digest) { data in
configFile.write(data)
try configFile.write(contentsOf: data)
}
try configFile.close()
// Pull VM's disk layers and decompress them into a disk file
let diskImplType: Disk.Type
let layers: [OCIManifestLayer]
if manifest.layers.contains(where: { $0.mediaType == legacyDiskV1MediaType }) {
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 }) {
diskImplType = DiskV1.self
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 {
let layers = manifest.layers.filter { $0.mediaType == diskV2MediaType }
if layers.isEmpty {
throw OCIError.ShouldBeAtLeastOneLayer
}
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)
defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...")
@@ -55,13 +53,19 @@ extension VMDirectory {
ProgressObserver(progress).log(defaultLogger)
do {
try await diskImplType.pull(registry: registry, diskLayers: layers, diskURL: diskURL,
concurrency: concurrency, progress: progress,
localLayerCache: localLayerCache)
try await DiskV2.pull(registry: registry, diskLayers: layers, diskURL: diskURL,
concurrency: concurrency, progress: progress,
localLayerCache: localLayerCache,
deduplicate: deduplicate)
} catch let error where error is FilterError {
throw RuntimeError.PullFailed("failed to decompress disk: \(error.localizedDescription)")
}
if deduplicate, let llc = localLayerCache {
// set custom attribute to remember deduplicated bytes
diskURL.setDeduplicatedBytes(llc.deduplicatedBytes)
}
// Pull VM's NVRAM file layer and store it in an NVRAM file
defaultLogger.appendNewLine("pulling NVRAM...")
@@ -76,19 +80,23 @@ extension VMDirectory {
}
let nvram = try FileHandle(forWritingTo: nvramURL)
try await registry.pullBlob(nvramLayers.first!.digest) { data in
nvram.write(data)
try nvram.write(contentsOf: data)
}
try nvram.close()
// Serialize VM's manifest to enable better de-duplication on subsequent "tart pull"'s
// Serialize VM's manifest to enable better deduplication on subsequent "tart pull"'s
try manifest.toJSON().write(to: manifestURL)
}
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, diskFormat: String) async throws -> RemoteName {
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, concurrency: UInt, labels: [String: String] = [:]) async throws -> RemoteName {
var layers = Array<OCIManifestLayer>()
// Read VM's config and push it as blob
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)
defaultLogger.appendNewLine("pushing config...")
let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb)
@@ -101,14 +109,7 @@ extension VMDirectory {
let progress = Progress(totalUnitCount: diskSize)
ProgressObserver(progress).log(defaultLogger)
switch diskFormat {
case "v1":
layers.append(contentsOf: try await DiskV1.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, progress: progress))
case "v2":
layers.append(contentsOf: try await DiskV2.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, progress: progress))
default:
throw RuntimeError.OCIUnsupportedDiskFormat(diskFormat)
}
layers.append(contentsOf: try await DiskV2.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, concurrency: concurrency, progress: progress))
// Read VM's NVRAM and push it as blob
defaultLogger.appendNewLine("pushing NVRAM...")
@@ -118,7 +119,8 @@ extension VMDirectory {
layers.append(OCIManifestLayer(mediaType: nvramMediaType, size: nvram.count, digest: nvramDigest))
// 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 manifest = OCIManifest(
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
+128 -4
View File
@@ -26,6 +26,9 @@ struct VMDirectory: Prunable {
var manifestURL: URL {
baseURL.appendingPathComponent("manifest.json")
}
var controlSocketURL: URL {
URL(fileURLWithPath: "control.sock", relativeTo: baseURL)
}
var explicitlyPulledMark: URL {
baseURL.appendingPathComponent(".explicitly-pulled")
@@ -139,14 +142,34 @@ struct VMDirectory: Prunable {
try vmConfig.save(toURL: configURL)
}
func resizeDisk(_ sizeGB: UInt16) throws {
if !FileManager.default.fileExists(atPath: diskURL.path) {
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
}
func resizeDisk(_ sizeGB: UInt16, format: DiskImageFormat = .raw) throws {
let diskExists = FileManager.default.fileExists(atPath: diskURL.path)
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 currentDiskFileLength = try diskFileHandle.seekToEnd()
let desiredDiskFileLength = UInt64(sizeGB) * 1000 * 1000 * 1000
if desiredDiskFileLength < currentDiskFileLength {
let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentDiskFileLength))
let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredDiskFileLength))
@@ -158,6 +181,84 @@ struct VMDirectory: Prunable {
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 {
let lock = try lock()
@@ -182,6 +283,14 @@ struct VMDirectory: Prunable {
try allocatedSizeBytes() / 1000 / 1000 / 1000
}
func deduplicatedSizeBytes() throws -> Int {
try configURL.deduplicatedSizeBytes() + diskURL.deduplicatedSizeBytes() + nvramURL.deduplicatedSizeBytes()
}
func deduplicatedSizeGB() throws -> Int {
try deduplicatedSizeBytes() / 1000 / 1000 / 1000
}
func sizeBytes() throws -> Int {
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
}
@@ -190,6 +299,21 @@ struct VMDirectory: Prunable {
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() {
FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil)
}
+23 -14
View File
@@ -24,6 +24,8 @@ class VMStorageHelper {
private static func missingVMWrap<R: Any>(_ name: String, closure: () throws -> R) throws -> R {
do {
return try closure()
} catch RuntimeError.PIDLockMissing {
throw RuntimeError.VMDoesNotExist(name: name)
} catch {
if error.isFileNotFound() {
throw RuntimeError.VMDoesNotExist(name: name)
@@ -47,6 +49,7 @@ extension Error {
}
enum RuntimeError : Error {
case Generic(_ message: String)
case VMConfigurationError(_ message: String)
case VMDoesNotExist(name: String)
case VMMissingFiles(_ message: String)
@@ -57,8 +60,11 @@ enum RuntimeError : Error {
case DiskAlreadyInUse(_ message: String)
case FailedToOpenBlockDevice(_ path: String, _ explanation: String)
case InvalidDiskSize(_ message: String)
case FailedToCreateDisk(_ message: String)
case FailedToResizeDisk(_ message: String)
case FailedToUpdateAccessDate(_ message: String)
case PIDLockFailed(_ message: String)
case PIDLockMissing(_ message: String)
case FailedToParseRemoteName(_ message: String)
case VMTerminationFailed(_ message: String)
case ImproperlyFormattedHost(_ host: String, _ hint: String)
@@ -68,9 +74,11 @@ enum RuntimeError : Error {
case ImportFailed(_ message: String)
case SoftnetFailed(_ message: String)
case OCIStorageError(_ message: String)
case OCIUnsupportedDiskFormat(_ format: String)
case SuspendFailed(_ message: String)
case PullFailed(_ message: String)
case VirtualMachineLimitExceeded(_ hint: String)
case VMSocketFailed(_ port: UInt32, _ explanation: String)
case TerminalOperationFailed(_ message: String)
}
protocol HasExitCode {
@@ -80,6 +88,8 @@ protocol HasExitCode {
extension RuntimeError : CustomStringConvertible {
public var description: String {
switch self {
case .Generic(let message):
return message
case .VMConfigurationError(let message):
return message
case .VMDoesNotExist(let name):
@@ -100,10 +110,16 @@ extension RuntimeError : CustomStringConvertible {
return "failed to open block device \(path): \(explanation)"
case .InvalidDiskSize(let message):
return message
case .FailedToCreateDisk(let message):
return message
case .FailedToResizeDisk(let message):
return message
case .FailedToUpdateAccessDate(let message):
return message
case .PIDLockFailed(let message):
return message
case .PIDLockMissing(let message):
return message
case .FailedToParseRemoteName(let cause):
return "failed to parse remote name: \(cause)"
case .VMTerminationFailed(let message):
@@ -122,12 +138,16 @@ extension RuntimeError : CustomStringConvertible {
return "Softnet failed: \(message)"
case .OCIStorageError(let 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):
return "Failed to suspend the VM: \(message)"
case .PullFailed(let message):
return message
case .VirtualMachineLimitExceeded(let 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
}
}
}
@@ -146,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
class VMStorageLocal: PrunableStorage {
let baseURL: URL = try! Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
let baseURL: URL
init() throws {
baseURL = try Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
}
private func vmURL(_ name: String) -> URL {
baseURL.appendingPathComponent(name, isDirectory: true)
+55 -27
View File
@@ -1,9 +1,13 @@
import Foundation
import Sentry
import OpenTelemetryApi
import Retry
class VMStorageOCI: PrunableStorage {
let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
let baseURL: URL
init() throws {
baseURL = try Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
}
private func vmURL(_ name: RemoteName) -> URL {
baseURL.appendingRemoteName(name)
@@ -27,12 +31,12 @@ class VMStorageOCI: PrunableStorage {
return digest
}
func open(_ name: RemoteName) throws -> VMDirectory {
func open(_ name: RemoteName, _ accessDate: Date = Date()) throws -> VMDirectory {
let vmDir = VMDirectory(baseURL: vmURL(name))
try vmDir.validate(userFriendlyName: name.description)
try vmDir.baseURL.updateAccessDate()
try vmDir.baseURL.updateAccessDate(accessDate)
return vmDir
}
@@ -140,10 +144,11 @@ class VMStorageOCI: PrunableStorage {
try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir }
}
func pull(_ name: RemoteName, registry: Registry, concurrency: UInt) async throws {
SentrySDK.configureScope { scope in
scope.setContext(value: ["imageName": name.description], key: "OCI")
}
func pull(_ name: RemoteName, registry: Registry, concurrency: UInt, deduplicate: Bool) async throws {
OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
key: "oci.image-name",
value: .string(name.description)
)
defaultLogger.appendNewLine("pulling manifest...")
@@ -177,18 +182,25 @@ class VMStorageOCI: PrunableStorage {
}
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)
// 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
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
try tmpVMDirLock.lock()
// Try to reclaim some cache space if we know the VM size in advance
if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
SentrySDK.configureScope { scope in
scope.setContext(value: ["imageUncompressedDiskSize": uncompressedDiskSize], key: "OCI")
}
OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
key: "oci.image-uncompressed-disk-size-bytes",
value: .int(Int(uncompressedDiskSize))
)
let otherVMFilesSize: UInt64 = 128 * 1024 * 1024
@@ -196,21 +208,32 @@ class VMStorageOCI: PrunableStorage {
}
try await withTaskCancellationHandler(operation: {
try await retry(maxAttempts: 5, backoff: .exponentialWithFullJitter(baseDelay: .seconds(5), maxDelay: .seconds(60))) {
try await retry(maxAttempts: 5) {
// Choose the best base image which has the most deduplication ratio
let localLayerCache = try await chooseLocalLayerCache(name, manifest, registry)
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency, localLayerCache: localLayerCache)
} recoverFromFailure: { error in
print("Error: \(error.localizedDescription)")
print("Attempting to re-try...")
if let llc = localLayerCache {
let deduplicatedHuman = ByteCountFormatter.string(fromByteCount: Int64(llc.deduplicatedBytes), countStyle: .file)
return .retry
if deduplicate {
defaultLogger.appendNewLine("found an image \(llc.name) that will allow us to deduplicate \(deduplicatedHuman), using it as a base...")
} else {
defaultLogger.appendNewLine("found an image \(llc.name) that will allow us to avoid fetching \(deduplicatedHuman), will try use it...")
}
}
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency, localLayerCache: localLayerCache, deduplicate: deduplicate)
} recoverFromFailure: { error in
if error is URLError {
print("Error pulling image: \"\(error.localizedDescription)\", attempting to re-try...")
return .retry
}
return .throw
}
try move(digestName, from: tmpVMDir)
transaction.finish()
}, onCancel: {
transaction.finish(status: SentrySpanStatus.cancelled)
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
})
} else {
@@ -225,6 +248,9 @@ class VMStorageOCI: PrunableStorage {
// are excluded from garbage collection
VMDirectory(baseURL: vmURL(name)).markExplicitlyPulled()
}
// to explicitly set the image as being accessed so it won't get pruned immediately
_ = try VMStorageOCI().open(name)
}
func linked(from: RemoteName, to: RemoteName) -> Bool {
@@ -246,15 +272,15 @@ class VMStorageOCI: PrunableStorage {
func chooseLocalLayerCache(_ name: RemoteName, _ manifest: OCIManifest, _ registry: Registry) async throws -> LocalLayerCache? {
// Establish a closure that will calculate how much bytes
// we'll de-duplicate if we re-use the given manifest
// we'll deduplicate if we re-use the given manifest
let target = Swift.Set(manifest.layers)
let calculateDeduplicatedBytes = { (manifest: OCIManifest) -> Int in
target.intersection(manifest.layers).map({ $0.size }).reduce(0, +)
let calculateDeduplicatedBytes = { (manifest: OCIManifest) -> UInt64 in
target.intersection(manifest.layers).map({ UInt64($0.size) }).reduce(0, +)
}
// Load OCI VM images and their manifests (if present)
var candidates: [(name: String, vmDir: VMDirectory, manifest: OCIManifest, deduplicatedBytes: Int)] = []
var candidates: [(name: String, vmDir: VMDirectory, manifest: OCIManifest, deduplicatedBytes: UInt64)] = []
for (name, vmDir, isSymlink) in try list() {
if isSymlink {
@@ -285,13 +311,15 @@ class VMStorageOCI: PrunableStorage {
candidates.append((name.description, vmDir, manifest, calculateDeduplicatedBytes(manifest)))
}
// Now, find the best match based on how many bytes we'll de-duplicate
let choosen = candidates.max { left, right in
// Now, find the best match based on how many bytes we'll deduplicate
let choosen = candidates.filter {
$0.deduplicatedBytes > 1024 * 1024 * 1024 // save at least 1GB
}.max { left, right in
return left.deduplicatedBytes < right.deduplicatedBytes
}
return try choosen.flatMap({ choosen in
try LocalLayerCache(choosen.vmDir.diskURL, choosen.manifest)
try LocalLayerCache(choosen.name, choosen.deduplicatedBytes, choosen.vmDir.diskURL, choosen.manifest)
})
}
}
@@ -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())
}
}
+3 -24
View File
@@ -14,7 +14,7 @@ final class LayerizerTests: XCTestCase {
do {
registryRunner = try await RegistryRunner()
} catch {
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil)
throw XCTSkip("Registry is unavailable: \(error)")
}
}
@@ -24,30 +24,9 @@ final class LayerizerTests: XCTestCase {
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, 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 {
// 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 {
try FileManager.default.removeItem(at: originalDiskFileURL)
}
@@ -57,7 +36,7 @@ final class LayerizerTests: XCTestCase {
let pulledDiskFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
print("pushing disk...")
let diskLayers = try await DiskV2.push(diskURL: originalDiskFileURL, registry: registry, chunkSizeMb: 0, progress: Progress())
let diskLayers = try await DiskV2.push(diskURL: originalDiskFileURL, registry: registry, chunkSizeMb: 0, concurrency: 4, progress: Progress())
print("pulling disk...")
try await DiskV2.pull(registry: registry, diskLayers: diskLayers, diskURL: pulledDiskFileURL, concurrency: 16, progress: Progress())
+1 -1
View File
@@ -10,7 +10,7 @@ final class RegistryTests: XCTestCase {
do {
registryRunner = try await RegistryRunner()
} catch {
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil)
throw XCTSkip("Registry is unavailable: \(error)")
}
}
+183
View File
@@ -0,0 +1,183 @@
import XCTest
@testable import tart
import Semaphore
final class SoftnetControlFDTests: XCTestCase {
func testConnectedUnixStreamSocketIsAccepted() throws {
var fds: [Int32] = [-1, -1]
XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0)
defer {
close(fds[0])
close(fds[1])
}
XCTAssertNoThrow(try Softnet.validateControlFD(fds[0]))
}
func testUnixDatagramSocketIsRejected() throws {
var fds: [Int32] = [-1, -1]
XCTAssertEqual(socketpair(AF_UNIX, SOCK_DGRAM, 0, &fds), 0)
defer {
close(fds[0])
close(fds[1])
}
XCTAssertThrowsError(try Softnet.validateControlFD(fds[0]))
}
func testUnconnectedUnixStreamSocketIsRejected() throws {
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
XCTAssertGreaterThan(fd, STDERR_FILENO)
defer { close(fd) }
XCTAssertThrowsError(try Softnet.validateControlFD(fd))
}
func testPipeIsRejected() throws {
var fds: [Int32] = [-1, -1]
XCTAssertEqual(pipe(&fds), 0)
defer {
close(fds[0])
close(fds[1])
}
XCTAssertThrowsError(try Softnet.validateControlFD(fds[0]))
}
func testStandardDescriptorsAreRejected() throws {
XCTAssertThrowsError(try Softnet.validateControlFD(STDIN_FILENO))
XCTAssertThrowsError(try Softnet.validateControlFD(STDOUT_FILENO))
XCTAssertThrowsError(try Softnet.validateControlFD(STDERR_FILENO))
}
func testStandardDescriptorsRemainOpenWhenInitializationFails() throws {
for fd in [STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO] {
let flags = fcntl(fd, F_GETFD)
XCTAssertNotEqual(flags, -1)
XCTAssertThrowsError(try Softnet(vmMACAddress: "02:00:00:00:00:01", controlFD: fd))
XCTAssertEqual(fcntl(fd, F_GETFD), flags)
}
}
func testControlChannelIsPassedToSoftnetAndVMFDRemainsDatagram() async throws {
let temporaryDirectory = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: false)
defer { try? FileManager.default.removeItem(at: temporaryDirectory) }
let executable = temporaryDirectory.appendingPathComponent("softnet")
let script = """
#!/usr/bin/env python3
import socket
import sys
assert sys.argv[1:] == ["--vm-fd", "0", "--vm-mac-address", "02:00:00:00:00:01", "--control-fd", "1"]
vm = socket.socket(fileno=0)
control = socket.socket(fileno=1)
assert vm.family == socket.AF_UNIX and vm.type == socket.SOCK_DGRAM
assert control.family == socket.AF_UNIX and control.type == socket.SOCK_STREAM
assert control.recv(4096) == b"softnet.policy.set\\n"
control.sendall(b"ok\\n")
"""
try script.write(to: executable, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
let previousPath = ProcessInfo.processInfo.environment["PATH"] ?? ""
setenv("PATH", "\(temporaryDirectory.path):\(previousPath)", 1)
defer { setenv("PATH", previousPath, 1) }
var fds: [Int32] = [-1, -1]
XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0)
defer { close(fds[1]) }
var timeout = timeval(tv_sec: 5, tv_usec: 0)
XCTAssertEqual(setsockopt(fds[1], SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size)), 0)
let semaphore = AsyncSemaphore(value: 0)
let softnet = try Softnet(vmMACAddress: "02:00:00:00:00:01", controlFD: fds[0])
try softnet.run(semaphore)
XCTAssertEqual(fcntl(fds[0], F_GETFD), -1)
XCTAssertEqual(errno, EBADF)
let request = Array("softnet.policy.set\n".utf8)
XCTAssertEqual(request.withUnsafeBytes { send(fds[1], $0.baseAddress, $0.count, 0) }, request.count)
var response = [UInt8](repeating: 0, count: 128)
let received = recv(fds[1], &response, response.count, 0)
XCTAssertGreaterThan(received, 0)
XCTAssertEqual(String(decoding: response.prefix(Int(max(received, 0))), as: UTF8.self), "ok\n")
await semaphore.wait()
}
func testControlFDIsClosedWhenSoftnetInitializationFails() throws {
let previousPath = ProcessInfo.processInfo.environment["PATH"] ?? ""
setenv("PATH", "/this/path/does/not/exist", 1)
defer { setenv("PATH", previousPath, 1) }
var fds: [Int32] = [-1, -1]
XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0)
defer { close(fds[1]) }
XCTAssertThrowsError(try Softnet(vmMACAddress: "02:00:00:00:00:01", controlFD: fds[0]))
XCTAssertEqual(fcntl(fds[0], F_GETFD), -1)
XCTAssertEqual(errno, EBADF)
}
func testControlFDIsClosedWhenSoftnetValidationFails() throws {
var fds: [Int32] = [-1, -1]
XCTAssertEqual(socketpair(AF_UNIX, SOCK_DGRAM, 0, &fds), 0)
defer { close(fds[1]) }
XCTAssertThrowsError(try Softnet(vmMACAddress: "02:00:00:00:00:01", controlFD: fds[0]))
XCTAssertEqual(fcntl(fds[0], F_GETFD), -1)
XCTAssertEqual(errno, EBADF)
}
func testControlFDImpliesSoftnet() throws {
let temporaryHome = try createTemporaryTartHome()
defer { try? FileManager.default.removeItem(at: temporaryHome) }
let previousHome = ProcessInfo.processInfo.environment["TART_HOME"]
setenv("TART_HOME", temporaryHome.path, 1)
defer { restoreEnvironment("TART_HOME", value: previousHome) }
let command = try Run.parse(["vm", "--net-softnet-control-fd", "3"])
XCTAssertTrue(command.netSoftnet)
XCTAssertEqual(command.netSoftnetControlFd, 3)
}
func testControlFDIsRejectedWithHostNetworking() throws {
let temporaryHome = try createTemporaryTartHome()
defer { try? FileManager.default.removeItem(at: temporaryHome) }
let previousHome = ProcessInfo.processInfo.environment["TART_HOME"]
setenv("TART_HOME", temporaryHome.path, 1)
defer { restoreEnvironment("TART_HOME", value: previousHome) }
XCTAssertThrowsError(
try Run.parse(["vm", "--net-host", "--net-softnet-control-fd", "3"])
)
}
private func createTemporaryTartHome() throws -> URL {
let temporaryHome = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
let vm = temporaryHome.appendingPathComponent("vms/vm")
try FileManager.default.createDirectory(at: vm, withIntermediateDirectories: true)
for name in ["config.json", "disk.img", "nvram.bin"] {
XCTAssertTrue(FileManager.default.createFile(atPath: vm.appendingPathComponent(name).path, contents: nil))
}
return temporaryHome
}
private func restoreEnvironment(_ name: String, value: String?) {
if let value = value {
setenv(name, value, 1)
} else {
unsetenv(name)
}
}
}
+2 -2
View File
@@ -13,7 +13,7 @@ class RegistryRunner {
let stdoutPipe = Pipe()
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/local/bin/docker")
proc.executableURL = URL(fileURLWithPath: "/opt/homebrew/bin/docker")
proc.arguments = arguments
proc.standardOutput = stdoutPipe
try proc.run()
@@ -31,7 +31,7 @@ class RegistryRunner {
init() async throws {
// Start container
let container = try Self.dockerCmd("run", "-d", "--rm", "-p", "5000", "registry:2")
let container = try Self.dockerCmd("run", "-d", "--rm", "-p", "127.0.0.1::5000", "registry:2")
.trimmingCharacters(in: CharacterSet.newlines)
containerID = container

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