Compare commits

...
217 Commits
Author SHA1 Message Date
Sam EstepandClaude Opus 5 9bb2af2434 Run ErrorReportingTask operations on the caller's actor (#1324)
VZVirtualMachine asserts that it is used on the queue it was created with,
which for tart is the main queue. Since #1262 replaced the unstructured Task
in "tart run"'s SIGUSR2 handler with ErrorReportingTask, that assertion fails:
Task.init carries @_inheritActorContext, but ErrorReportingTask.init did not,
so an operation written inside MainActor-isolated Run.runOnMainThread() is formed
in a nonisolated init and runs on the cooperative pool, not the main queue.

The result is that asking a VM to stop gracefully kills it instead. Sending
SIGUSR2, which #842 hooked to requestStop() for exactly this purpose, crashes
the process:

    Thread 1  queue: com.apple.root.default-qos.cooperative
      _dispatch_assert_queue_fail
      dispatch_assert_queue
      -[VZVirtualMachine requestStopWithError:]
      closure in Run.runOnMainThread()
      closure in ErrorReportingTask.init(_:operation:)

The guest then loses power without a chance to flush, and on a Linux guest
with ext4's default delayed allocation that discards whatever had not been
written back yet. The same applies to the requestStop() in
applicationShouldTerminate(), i.e. closing the window of a VM run with a GUI.

Give the operation the same @_inheritActorContext that Task.init has, so that
wrapping a call in ErrorReportingTask no longer changes where it runs.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 15:05:42 -07:00
Yibo Zhuang cdb3579c79 Fix control socket paths with special characters (#1328) 2026-09-07 10:40:58 -07:00
Yibo Zhuang 3f15df9e3c Preserve stdout when running Softnet without a control FD (#1329) 2026-09-07 09:37:51 -07:00
Yibo Zhuang 1b4813f210 Preserve running VM directories during replacement (#1321) 2026-09-01 18:20:18 -07:00
Yibo Zhuang c4cf73a819 Pin CI and build actions to full commit SHAs (#1325) 2026-09-02 00:41:44 +00:00
Yibo Zhuang 16d186c253 Add stacked disk integration coverage and documentation (#1316) 2026-08-18 07:16:00 -07:00
Yibo Zhuang 5f8795bd4e Complete stacked disk command support (#1315) 2026-08-17 15:20:50 -07:00
Yibo Zhuang f83cba84af Support stacked VM archive import and export (#1314) 2026-08-17 14:22:18 -07:00
Yibo Zhuang 32a627c8c5 Make stacked content pruning reference-aware (#1313) 2026-08-17 13:05:07 -07:00
Yibo Zhuang 4162ca1831 Fail VM startup when its control socket cannot bind (#1311) 2026-08-17 11:59:39 +01:00
Yibo Zhuang 4ce8a115f7 Add OCI transport and base clone with DiskImageKit (#1304)
* Add OCI transport and base clone with DiskImageKit

* Address stacked OCI pull review feedback

* Stream file digest hashing

* Lock frozen overlays during push
2026-08-12 12:10:28 -07:00
Yibo Zhuang f87b57bbc5 Begin work on adding DiskImageKit to tart (#1303)
This is first of several changes to add support for the new
DiskImageKit ASIF layers to tart VM images.

This change is focused on laying down the OCI media type for
ASIF layers, content addressable store structure, as well
as the VMDirectory structure for supporting layers.

Add DiskImageStack type to model VM image using DiskImage APIs.
2026-08-11 09:13:25 -07:00
edi-oai a438e2d031 tart {list,get}: display humanized byte units (#1301) 2026-08-05 22:11:37 +01:00
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
Nikolay Edigaryev 1e74e268a5 DiskV2: change layer size to 512 * 1024 * 1024 bytes (#866)
Needed to test https://github.com/cirruslabs/tart/pull/864.
2024-07-17 16:37:45 +00:00
Nikolay Edigaryev bff344fb7f tart login: better error when an improperly formatted host is provided (#863)
* tart login: better error when an improperly formatted host is provided

* Revert old behavior w.r.t. URLComponents()
2024-07-15 18:36:09 +04:00
Nikolay Edigaryev ababe8cefc tart pull: choose across multiple VM images to deduplicate against (#862)
This is accomplished by saving the OCI VM image manifests on "tart pull"
in "manifest.json" file and then using them on successive "tart pull"'s
to find the best candidate that results in the most de-duplication,
measured in bytes.
2024-07-15 18:36:01 +04:00
Fedor Korotkov ea5313698e Do not prune running VMs (#861)
Also prevent pushing of a running VM

Fixes #860
2024-07-15 07:10:51 -04:00
Fedor Korotkov 679289d7ab Added Figma as a user (#858)
See https://www.figma.com/open-source/

Plus reworked users section since now we can curate the best representative.
2024-07-10 15:06:01 +00:00
Nikolay Edigaryev 5eccdf7412 Support customizing VM disks and mounting remote VMs in tart run (#847)
* Support remote VM names in --disk command-line argument

* tart set: introduce "--disk" to support replacing VM's disk contents

* Complete the code comment
2024-07-02 18:12:35 +04:00
Nikolay Edigaryev 63e3235d91 tart run: pick up --net-softnet-allow when using --net-host (#853) 2024-07-02 16:39:51 +04:00
Nikolay Edigaryev a760a431c3 Jumping through the hoops: SSH jump host functionality in Orchard (#844) 2024-06-20 22:39:41 +00:00
Tor Arne Vestbø bf5081b3d9 Hook SIGUSR2 to requestStop (#842)
For macOS this brings up a dialog, asking the user if they are sure
they want to shut down, which makes this less useful for automated
graceful shutdowns, but it may behave better on Linux, and there
might be ways to instruct macOS to not ask the user, so it's still
a nice feature, and aligns with the SIGUSR1 for suspend, and SIGINT
for non-graceful shutdown.
2024-06-17 12:11:00 -04:00
Fedor Korotkov 017592075f Drop Monterey Support (#843)
* Drop Monterey Support

People will still be able to run and SSH into Monterey VMs or use VNC but pointing devices/keyboard won't work.

Fixes #841

* Fixed x86 build
2024-06-17 15:20:21 +00:00
Fedor Korotkov 84e1ae2b38 Fixed GoReleaser 2.0.0 (#839)
GoReleaser changes some flags
2024-06-05 19:09:56 +04:00
Fedor Korotkov d50e113300 Rearrange companies
To make the patter pretty
2024-06-05 08:32:38 -04:00
marc fce52f1514 Add Atlassian as Tart user (#838) 2024-06-05 08:31:23 -04:00
Fedor Korotkov 9484b8b2c9 Update Sentry Token (#836)
The latest release has this error:

> error: Project not found. Please check that you entered the project and organization slugs correctly.

Which seems indicating that Auth is broken and we are getting 404? In the Sentry Settings I didn't find any token which I find strange. So I created one and re-encrypted.
2024-06-01 13:11:05 +00:00
Fedor Korotkov dd46033812 Friendly decompression error message (#835)
* Friendly decompression error message

* Wrap FilterError
2024-05-31 14:47:41 +00:00
Fedor Korotkov c655288de7 Fancy Social Cards (#830) 2024-05-22 16:07:08 +04:00
Nikolay Edigaryev 204002f776 VMStorageOCI: percent-encode the colon in RemoteName's host (#828)
* VMStorageOCI: percent-encode the colon in RemoteName's host

* Do not use String extensions and add a comment
2024-05-21 11:21:28 -04:00
Nikolay Edigaryev a0ae2f4e66 integration-tests: downgrade "requests" package to 2.31.0 (#829)
To fix the build failing.

See https://github.com/psf/requests/issues/6707 for more details.
2024-05-21 13:35:33 +00:00
Nikolay Edigaryev 7c386e3466 tart pull: try to re-use local VM image layers to speed-up the pulling (#825)
* Remove unused pullFromRegistry() method with "reference" argument

* tart pull: try to deduplicate disk layers to speed-up the pulling
2024-05-16 19:43:56 +04:00
Nikolay Edigaryev dbbd716214 tart push: use fixed size chunks to allow for better deduplication (#821) 2024-05-14 19:23:04 +04:00
William Theaker 13d5ddb4a4 Minor documentation improvements. (#819)
* Minor documentation improvements.

* Fix MD031

* Add sudo to mount instructions.
2024-05-13 12:44:26 -04:00
Fedor Korotkov 626316a4cd Update manual installation instructions (#816)
Fixes #815
2024-05-06 23:10:36 +04:00
Fedor Korotkov fbe35302c2 Use warn images (#812) 2024-05-05 12:07:00 +04:00
Fedor Korotkov e1353f4540 [docs] fixed Cirrus Runners link (#813) 2024-05-05 12:06:39 +04:00
Fedor Korotkov 985db24474 Introduce --random-mac and --random-serial flags for tart set (#809)
To generate new MAC address and/or serial number for a given VM.
2024-05-02 18:27:49 +04:00
Nikolay Edigaryev 1d01bf63fb tart run: resolve VM's IP using ARP when using --net-bridged and --vnc (#811) 2024-05-02 18:04:26 +04:00
Andrew Malchuk 3ff3850da2 Add support pasting clipboard from host for Linux VMs (#806)
* Added partial support pasting clipboard from host (only for Linux VMs)

* Added option "--no-clipboard" to run command
2024-05-02 09:48:25 +04:00
Tor Arne Vestbø c6e8d0bfd7 Gracefully stop vm on tart stop (#808)
* Give Virtualization.framework a chance to stop the VM on tart stop

We were letting the CancellationError bubble up all the way until
it terminated app, which meant we didn't hit the shutdown code
in run(), stopping the VM and the network.

We now catch CancellationError and proceed to gracefully shut down.

We only stop the VM if it's still running, as a VM that has been
stopped via the menu can't be stopped again.

* Gracefully shut down VM when Tart is quit via menu

Normally the quit action will result in AppKit calling exit(),
but we want to gracefully shut down the VM, so we use the same
path as for closing of the VM window, namely signal our own
process with SIGINT or SIGUSR1.

If that doesn't work we let AppKit terminate as before.

This fixes the "Warning: NSActivity <_NSActivityAssertion:
0x600001f785a0> was ended multiple times" warning seen on
the console when quitting Tart via the menu.

* Activate Tart after application finishes launching

This ensures that the VM window has been shown by the time we
activate, so that we consistently activate and bring the VM
window to the front.
2024-04-30 09:27:41 -04:00
Fedor Korotkov 755aad4d7c Check all VMs for MAC collision (#801)
* Check all VMs for MAC collision

Before only suspendable VMs were getting checked. Not sure why. It makes sense to check all.

* Always acquire a lock
2024-04-25 09:26:42 -04:00
Nikolay Edigaryev 9f38441a42 Fix pathHasMode() and only check for S_IFBLK (#800) 2024-04-23 11:12:35 -04:00
Fedor Korotkov 3d46c4e6c2 Support all NBD schemas (#799)
See https://github.com/NetworkBlockDevice/nbd/blob/master/doc/uri.md#nbd-uri-scheme

Fixes #792
2024-04-23 18:17:42 +04:00
Nikolay Edigaryev e59221f6a0 tart run: do not require root to mount a block device (#798) 2024-04-23 17:03:02 +04:00
Fedor Korotkov c6e99345cd Validate Suspendability (#797)
And show "Suspend" menu item based on `--suspnedable` flag

Fixes #796
2024-04-22 10:07:01 +00:00
Nikolay EdigaryevandFedor Korotkov 8bc2e99f63 Document how to mount the shared directory on Linux at boot time (#793)
* Document how to mount the shared directory on Linux at boot time

* Use admonition

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

---------

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>
2024-04-17 23:34:01 -04:00
Fedor Korotkov f36f86b61c [docs] lint to Cirrus Runners site (#789) 2024-04-12 21:33:15 +04:00
Nikolay Edigaryev 79084555f6 tart pull: retry VM pull with exponential backoff (#788) 2024-04-12 21:32:03 +04:00
Fedor Korotkov 896d03ce0b Fixed Swift Warning (#787)
* Fixed Swift Warning

Plus updated all the dependencies and Swift Tools.

Fixes #785

* Fixed race condition
2024-04-11 19:53:10 +04:00
Fedor KorotkovandNikolay Edigaryev 99c91cbf87 Allow mounting NBD disks (#786)
* Allow mounting NBD disks

Fixes #759

* Apply suggestions from code review

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

* Removed unnecessary docs

---------

Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>
2024-04-11 17:20:58 +04:00
Bartek Pacia da8afa1348 Add shell completions (#780)
* add VM completion for run command

* add VM completion for stop command

* create ShellCompletions utilities

* add shell completions to some commands

* add shell completion for fqn command

* run command: fix tiny typo

* add shell completion for get command

* more shell completions

* remove unnecessary `try`

* refactor ShellCompletions file
2024-04-11 06:22:50 -04:00
Nikolay Edigaryev 97b7ffef52 tart stop: throw RuntimeError.VMNotRunning consistently and use enumeration instead of strings (#784)
* Use enumeration instead of just strings for VMDirectory state

* tart stop: throw RuntimeError.VMNotRunning consistently
2024-04-10 14:53:04 +00:00
Tor Arne Vestbø 13e7794bfc Generate shell completions by calling tart.app executable (#775) 2024-04-03 01:22:52 +02:00
Nikolay Edigaryev b7b3b702ac Sentry: upgrade and attach command-line arguments (#774)
* Sentry: upgrade and attach command-line arguments

* Sentry's setContext(): explicitly pass a String
2024-04-02 18:31:16 +04:00
Tor Arne Vestbø 560dba79e4 Report operating system in tart get (#772)
Can be useful to know from outside the VM.
2024-03-31 14:18:26 -04:00
Tor Arne Vestbø 2b33b8f9e6 Report progress when downloading IPSW files (#768)
The URLSession async/await functions do not report progress through
the normal URLSessionTaskDelegate callbacks, as reported in:

 https://developer.apple.com/forums/thread/723015

We don't want to use URLSession.bytes, as that results in a much
slower download speed compared to URLSession.download, but we can
work around the lack of progress callbacks by observing the
progress on the URLSessionTask itself.

Fixes #767
2024-03-28 19:15:06 +04:00
Tor Arne Vestbø d8b010c79c Support cancellation of installation process (#770)
We wrap the installation with a withTaskCancellationHandler, which
ensures that the SIGINT signal handling code in main() will trigger
a cancellation of the installer.

As the VZMacOSInstaller must be both created and interacted with
on the VM's queue, which in our case is the main queue, we need
to move the logic to a separate function tagged with @MainActor.
This makes sense either way, as it cleans up the code a bit.
2024-03-28 00:14:33 +04:00
Nikolay Edigaryev 5cd83c38cd Introduce Golang-based benchmarking utility (#769)
* Introduce Golang-based benchmarking utility

* benchmark fio: properly configure logger level

* benchmark: properly terminate on Ctrl+C when initializing/running Tart

* benchmark(fio): increase runtime to 30 seconds

* benchmark(fio): IOPS are already per second

* benchmark(fio): --numjobs 1 --iodepth 1 --end_fsync 1

* benchmark(README.md): add results
2024-03-27 18:45:01 +04:00
Bartek Pacia 1a3b862631 goreleaser: set up automatic installation of shell completion files (#766) 2024-03-26 12:08:09 +04:00
Fedor Korotkov ae2d59e5c2 Revert "Do not magically set --no-graphics when --vnc is passed (#763)
* Revert "Do not magically set `--no-graphics` when `--vnc` is passed (#732)"

This reverts commit a48f4d4ec9.

* Mark `--graphics` as private
2024-03-19 08:28:27 +00:00
Evgeniy Baranov 7eac45702b Fix the --insecure flag issue by disabling ATS in Info.plist (#760) 2024-03-19 03:42:14 -04:00
Nikolay Edigaryev e06d89f95d integration-tests: test_run() with --no-graphics (#757) 2024-03-12 15:12:02 +04:00
206 changed files with 12646 additions and 2160 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 -96
View File
@@ -1,30 +1,29 @@
use_compute_credits: true
env:
XCODE_TAG: 15.2
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
@@ -41,7 +40,7 @@ task:
name: Lint
alias: lint
macos_instance:
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
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:
@@ -58,105 +57,21 @@ task:
name: Build ($BUILD_ARCH)
alias: build
macos_instance:
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
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-sonoma-xcode:$XCODE_TAG
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-sonoma-xcode:$XCODE_TAG
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[!c16a5cf7da5f856b4bc2f21fe8cb7aa2a6c981f851c094ed4d3025fd02ea59a58a86cee8b193a69a1fc20fa217e56ac3!]
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@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
- 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@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
- 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@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
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/*
+6
View File
@@ -8,6 +8,9 @@ tart.xcodeproj/
# AppCode
.idea/
# VS Code
.vscode/
# Swift
.build/
@@ -16,3 +19,6 @@ dist/
# mkdocs
.cache
# mkdocs-material
site
+51 -21
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,18 +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"
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.
+202 -30
View File
@@ -1,12 +1,31 @@
{
"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,12 +38,48 @@
}
},
{
"identity" : "sentry-cocoa",
"identity" : "grpc-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/getsentry/sentry-cocoa",
"location" : "https://github.com/grpc/grpc-swift.git",
"state" : {
"revision" : "d277532e1c8af813981ba01f591b15bbdd735615",
"version" : "8.8.0"
"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" : "2543679282aa6f6c8ecf2138acd613ed20790bc2",
"version" : "0.1.0"
}
},
{
@@ -32,8 +87,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-algorithms",
"state" : {
"revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e",
"version" : "1.0.0"
"revision" : "f6919dfc309e7f1b56224378b11e28bab5bccc42",
"version" : "1.2.0"
}
},
{
@@ -41,17 +96,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser",
"state" : {
"revision" : "f3c9084a71ef4376f2fabbdf1d3d90a49f1fabdb",
"version" : "1.1.2"
}
},
{
"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"
}
},
{
@@ -59,8 +105,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "919eb1d83e02121cdb434c7bfc1f0c66ef17febe",
"version" : "1.0.2"
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
"version" : "1.3.0"
}
},
{
@@ -68,8 +114,89 @@
"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"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"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"
}
},
{
@@ -81,13 +208,49 @@
"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" : "df9d7b185d2e433147ec0083a73c257e665eea0d",
"version" : "0.2.4"
}
},
{
"identity" : "swift-sysctl",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sersoft-gmbh/swift-sysctl.git",
"state" : {
"revision" : "71fd64ee84819bb19fbecfb36d5a4503726b6fb7",
"version" : "1.6.0"
"revision" : "a91be36de6803ebe48f678699dfd0694c2200d2f",
"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"
}
},
{
@@ -95,8 +258,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/malcommac/SwiftDate",
"state" : {
"revision" : "6190d0cefff3013e77ed567e6b074f324e5c5bf5",
"version" : "6.3.1"
"revision" : "5d943224c3bb173e6ecf27295611615eba90c80e",
"version" : "7.0.0"
}
},
{
@@ -104,8 +267,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/nicklockwood/SwiftFormat",
"state" : {
"revision" : "da637c398c5d08896521b737f2868ddc2e7996ae",
"version" : "0.50.6"
"revision" : "ab6844edb79a7b88dc6320e6cee0a0db7674dac3",
"version" : "0.54.5"
}
},
{
@@ -125,7 +288,16 @@
"branch" : "master",
"revision" : "e03289289155b4e7aa565e32862f9cb42140596a"
}
},
{
"identity" : "thrift-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/undefinedlabs/Thrift-Swift",
"state" : {
"revision" : "18ff09e6b30e589ed38f90a1af23e193b8ecef8e",
"version" : "1.1.2"
}
}
],
"version" : 2
"version" : 3
}
+26 -13
View File
@@ -1,4 +1,4 @@
// swift-tools-version:5.7
// swift-tools-version:5.10
import PackageDescription
let package = Package(
@@ -10,32 +10,45 @@ let package = Package(
.executable(name: "tart", targets: ["tart"])
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.1.2"),
.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.0.0"),
.package(url: "https://github.com/apple/swift-async-algorithms", branch: "main"),
.package(url: "https://github.com/malcommac/SwiftDate", from: "6.3.1"),
.package(url: "https://github.com/antlr/antlr4", branch: "dev"),
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")),
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.6"),
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.8.0"),
.package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0"),
.package(url: "https://github.com/malcommac/SwiftDate", from: "7.0.0"),
.package(url: "https://github.com/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/cfilipov/TextTable", branch: "master"),
.package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.0.0"),
.package(url: "https://github.com/orchetect/SwiftRadix", from: "1.3.0")
.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",
+24 -36
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.
Many companies are using Tart in their internal setups. Here are just a few of them:
<p align="center">
<a href="https://tart.run/integrations/github-actions/?utm_source=github&utm_medium=referral" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/CirrusRunnersForGHA.png" height="65"/>
<a href="https://atlassian.com/" target=_blank>
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Atlassian.png" height="65"/>
</a>
</p>
Many companies are using Tart in their internal setups. Here are a few of them:
<p align="center">
<a href="https://ahrefs.com/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/ahrefs.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"/>
<a href="https://www.figma.com/" target=_blank>
<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://shape.dk/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/shape.png" height="65"/>
<a href="https://krisp.ai/" target=_blank>
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
</a>
<a href="https://suran.com/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Suran.png" height="65"/>
<a href="https://testingbot.com/" target=_blank>
<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://uphold.com/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Uphold.png" height="65"/>
<a href="https://cirrus-ci.org/" target=_blank>
<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 [adding yourself to the list above](/Resources/Users/HowToAddYourself.md).
<p align="center">
<a href="https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws?utm_source=github&utm_medium=referral" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/AWSMarkeplaceLogo.png" height="90"/>
</a>
</p>
**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 -12
View File
@@ -2,17 +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>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: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

-4
View File
@@ -1,4 +0,0 @@
If you'd like to highlight your use of Tart, please create a `456px` by `130px` logo and create a PR
that adds it to `README.md` in alphabetical order. Don't forget to include a small description of your usage pattern.
You can refer to `Background.png` as a base for your logo.
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"
+90 -17
View File
@@ -8,17 +8,15 @@ struct Clone: AsyncParsableCommand {
discussion: """
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.
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. 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.
"""
)
@Argument(help: "source VM name")
@Argument(help: "source VM name", completion: .custom(completeMachines))
var sourceName: String
@Argument(help: "new VM name")
@@ -30,6 +28,15 @@ 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
@Flag(help: "create a stacked disk that uses the source image as an immutable base")
var stacked: 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,16 +48,45 @@ struct Clone: AsyncParsableCommand {
}
func run() async throws {
let ociStorage = VMStorageOCI()
let localStorage = VMStorageLocal()
let ociStorage = try VMStorageOCI()
let localStorage = try VMStorageLocal()
let remoteName = try? RemoteName(sourceName)
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
if stacked {
guard remoteName != nil else {
throw ValidationError("--stacked requires a remote image")
}
try DiskImageStack.requireSupport()
}
if let remoteName, try !ociStorage.hasUsableCachedImageForClone(remoteName, requireManifest: stacked) {
// 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)
var resolvedManifest: (manifest: OCIManifest, data: Data)?
// Fail before pulling disk content when this host cannot create a writable stacked disk.
if !stacked {
let (manifest, manifestData) = try await registry.pullManifest(reference: remoteName.reference.value)
if manifest.layers.contains(where: { $0.mediaType == asifOverlayMediaType }) {
try DiskImageStack.requireSupport()
}
resolvedManifest = (manifest, manifestData)
}
try await ociStorage.pull(
remoteName,
registry: registry,
concurrency: concurrency,
deduplicate: deduplicate,
requireManifest: stacked,
resolvedManifest: resolvedManifest
)
}
let sourceVM = try VMStorageHelper.open(sourceName)
if sourceVM.isStackedVM || sourceVM.isStackedCachedImage {
try DiskImageStack.requireSupport()
}
let tmpVMDir = try VMDirectory.temporary()
// Lock the temporary VM directory to prevent it's garbage collection
@@ -62,20 +98,57 @@ struct Clone: AsyncParsableCommand {
let lock = try FileLock(lockURL: Config().tartHomeDir)
try lock.lock()
let sourceState = try sourceVM.state()
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
&& sourceVM.state() != "suspended"
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
&& sourceState != .Suspended
if stacked {
guard sourceVM.isStandalone else {
throw ValidationError("--stacked cannot use an image that already has a stacked disk")
}
guard try VMConfig(fromURL: sourceVM.configURL).os == .darwin else {
throw ValidationError("--stacked currently supports only macOS images")
}
try sourceVM.cloneAsStackedBase(to: tmpVMDir, generateMAC: generateMAC)
} else if sourceVM.isStackedCachedImage {
try sourceVM.cloneStacked(to: tmpVMDir, copyWritableOverlay: false, generateMAC: generateMAC)
} else if sourceVM.isStackedVM {
guard sourceState == .Stopped else {
throw RuntimeError.VMConfigurationError("VM \"\(sourceName)\" must be stopped before cloning")
}
try sourceVM.cloneStacked(to: tmpVMDir, copyWritableOverlay: true, generateMAC: generateMAC)
} else {
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
}
try localStorage.move(newName, from: tmpVMDir)
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.
if sourceVM.isStandalone {
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)
}
} else if sourceVM.isStackedVM || sourceVM.isStackedCachedImage {
let clonedVM = try localStorage.open(newName)
// A stacked clone owns only its writable overlay locally, but that
// overlay may grow to the full guest-visible disk block layout at
// runtime. Reclaim against the clone so it is not pruned itself.
let unallocatedBytes = try clonedVM.diskSizeBytes() - clonedVM.allocatedSizeBytes()
let reclaimBytes = min(unallocatedBytes, Int(pruneLimit) * 1024 * 1024 * 1024)
if reclaimBytes > 0 {
try Prune.reclaimIfNeeded(UInt64(reclaimBytes), clonedVM)
}
}
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
try? tmpVMDir.removeFromDisk()
})
}
}
+16 -4
View File
@@ -10,15 +10,18 @@ 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")
var linux: Bool = false
@Option(help: ArgumentHelp("Disk size in Gb"))
@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,14 +66,18 @@ 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)
}
// Publish under the same lock that run holds while opening VM files.
let storageLock = try FileLock(lockURL: Config().tartHomeDir)
try storageLock.lock()
defer { withExtendedLifetime(storageLock) {} }
try VMStorageLocal().move(name, from: tmpVMDir)
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
+1 -1
View File
@@ -5,7 +5,7 @@ import SwiftUI
struct Delete: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Delete a VM")
@Argument(help: "VM name")
@Argument(help: "VM name", completion: .custom(completeMachines))
var name: [String]
func run() async throws {
+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.absoluteURL.path(percentEncoded: false))
}
// 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
}
+2 -2
View File
@@ -4,10 +4,10 @@ import Foundation
struct Export: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Export VM to a compressed .tvm file")
@Argument(help: "Source VM name.")
@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 {
+1 -1
View File
@@ -5,7 +5,7 @@ import SystemConfiguration
struct FQN: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Get a fully-qualified VM name", shouldDisplay: false)
@Argument(help: "VM name")
@Argument(help: "VM name", completion: .custom(completeMachines))
var name: String
func run() async throws {
+18 -5
View File
@@ -2,10 +2,12 @@ import ArgumentParser
import Foundation
fileprivate struct VMInfo: Encodable {
let OS: OS
let CPU: Int
let Memory: UInt64
let Disk: Int
let Size: String
let Disk: HumanReadableByteCount
let DiskFormat: String
let Size: HumanReadableByteCount
let Display: String
let Running: Bool
let State: String
@@ -14,7 +16,7 @@ fileprivate struct VMInfo: Encodable {
struct Get: AsyncParsableCommand {
static var configuration = CommandConfiguration(commandName: "get", abstract: "Get a VM's configuration")
@Argument(help: "VM name.")
@Argument(help: "VM name.", completion: .custom(completeLocalMachines))
var name: String
@Option(help: "Output format: text or json")
@@ -25,8 +27,19 @@ struct Get: AsyncParsableCommand {
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
let info = VMInfo(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())
let info = VMInfo(
OS: vmConfig.os,
CPU: vmConfig.cpuCount,
Memory: memorySizeInMb,
Disk: HumanReadableByteCount(try vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
DiskFormat: vmConfig.diskFormat.rawValue,
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) {
String(format: "%.3f", Float($0) / 1000 / 1000 / 1000)
},
Display: vmConfig.display.description,
Running: try vmDir.running(),
State: try vmDir.state().rawValue
)
print(format.renderSingle(info))
}
}
+29 -14
View File
@@ -2,30 +2,27 @@ 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 {
static var configuration = CommandConfiguration(abstract: "Get VM's IP address")
@Argument(help: "VM name")
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
var name: String
@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 {
@@ -61,7 +60,23 @@ struct IP: AsyncParsableCommand {
return ip
}
case .dhcp:
if let leases = try Leases(), let ip = try leases.ResolveMACAddress(macAddress: vmMACAddress) {
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.absoluteURL.path(percentEncoded: false))
}
if let ip = try await AgentResolver.ResolveIP(controlSocketURL.relativePath) {
return ip
}
}
+10 -4
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,10 +17,13 @@ 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()
defer {
try? tmpVMDir.removeFromDisk()
}
// Lock the temporary VM directory to prevent it's garbage collection
// while we're running
@@ -30,6 +33,9 @@ struct Import: AsyncParsableCommand {
// Populate the temporary VM directory with the export file contents
print("importing...")
try tmpVMDir.importFromArchive(path: path)
guard tmpVMDir.initialized else {
throw RuntimeError.ImportFailed("archive does not contain a runnable VM")
}
try await withTaskCancellationHandler(operation: {
// Acquire a global lock
@@ -45,7 +51,7 @@ struct Import: AsyncParsableCommand {
try lock.unlock()
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
try? tmpVMDir.removeFromDisk()
})
}
}
+34 -5
View File
@@ -5,8 +5,9 @@ import SwiftUI
fileprivate struct VMInfo: Encodable {
let Source: String
let Name: String
let Disk: Int
let Size: Int
let Disk: HumanReadableByteCount
let Size: HumanReadableByteCount
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())
try VMInfo(
Source: "local",
Name: name,
Disk: HumanReadableByteCount(try vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 },
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())
try VMInfo(
Source: "OCI",
Name: name,
Disk: HumanReadableByteCount(try vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 },
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)
}
}
}
+5 -2
View File
@@ -49,9 +49,10 @@ struct Login: AsyncParsableCommand {
])
if !noValidate {
let registry = try Registry(host: host, namespace: "", insecure: insecure,
credentialsProviders: [credentialsProvider])
do {
let registry = try Registry(host: host, namespace: "", insecure: insecure,
credentialsProviders: [credentialsProvider])
try await registry.ping()
} catch {
throw RuntimeError.InvalidCredentials("invalid credentials: \(error)")
@@ -63,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)>) {
+68 -55
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\"")
}
@@ -81,27 +81,34 @@ struct Prune: AsyncParsableCommand {
}
static func pruneSpaceBudget(prunableStorages: [PrunableStorage], spaceBudgetBytes: UInt64) throws {
let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() > $1.accessDate() }
while true {
let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() > $1.accessDate() }
var spaceBudgetBytes = spaceBudgetBytes
var prunablesToDelete: [Prunable] = []
var remainingBudgetBytes = spaceBudgetBytes
var prunableToDelete: Prunable?
for prunable in prunables {
let prunableSizeBytes = UInt64(try prunable.allocatedSizeBytes())
for prunable in prunables {
let prunableSizeBytes = UInt64(try prunable.allocatedSizeBytes())
if prunableSizeBytes <= spaceBudgetBytes {
// Don't mark for deletion as
// there's a budget available
spaceBudgetBytes -= prunableSizeBytes
} else {
// Mark for deletion
prunablesToDelete.append(prunable)
if prunableSizeBytes <= remainingBudgetBytes {
// Don't mark for deletion as there is budget available
remainingBudgetBytes -= prunableSizeBytes
} else {
prunableToDelete = prunable
break
}
}
}
try prunablesToDelete.forEach { try $0.delete() }
guard let prunableToDelete else {
return
}
// Deleting one cached stacked image can change which remaining image
// owns shared immutable content. Rebuild before choosing another.
try prunableToDelete.delete()
}
}
static func reclaimIfNeeded(_ requiredBytes: UInt64, _ initiator: Prunable? = nil) throws {
@@ -109,9 +116,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 +131,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
}
@@ -148,42 +152,51 @@ struct Prune: AsyncParsableCommand {
try Prune.reclaimIfPossible(requiredBytes - volumeAvailableCapacityCalculated, initiator)
}
private static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws {
let transaction = SentrySDK.startTransaction(name: "Pruning cache", operation: "prune", bindToScope: true)
defer { transaction.finish() }
static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws {
let span = OTel.shared.tracer.spanBuilder(spanName: "prune").startSpan()
defer { span.end() }
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() < $1.accessDate() }
let prunableStorages: [PrunableStorage] = [try VMStorageOCI(), try IPSWCache()]
let prunables = {
try prunableStorages
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() < $1.accessDate() }
}
// Does it even make sense to start?
let cacheUsedBytes = try prunables.map { try $0.allocatedSizeBytes() }.reduce(0, +)
if cacheUsedBytes < reclaimBytes {
let initialPrunables = try prunables()
let initialCacheUsedBytes = try initialPrunables.map { try $0.allocatedSizeBytes() }.reduce(0, +)
guard let reclaimBytes = Int(exactly: reclaimBytes), initialCacheUsedBytes >= reclaimBytes else {
return
}
var cacheReclaimedBytes: Int = 0
let targetCacheUsedBytes = initialCacheUsedBytes - reclaimBytes
var currentCacheUsedBytes = initialCacheUsedBytes
let initiatorPath = initiator.map {
$0.url.resolvingSymlinksInPath().standardizedFileURL.path
}
var it = prunables.makeIterator()
while cacheReclaimedBytes <= reclaimBytes {
guard let prunable = it.next() else {
while currentCacheUsedBytes > targetCacheUsedBytes {
// Deleting one cached stacked image can transfer ownership of shared
// immutable content to another record without reclaiming those bytes.
// Rebuild the candidates after every deletion so automatic pruning
// measures the cache that remains rather than a stale ownership snapshot.
guard let prunable = try prunables().first(where: {
$0.url.resolvingSymlinksInPath().standardizedFileURL.path != initiatorPath
}) else {
break
}
if prunable.url == initiator?.url.resolvingSymlinksInPath() {
// do not prune the initiator
continue
}
let allocatedSizeBytes = try prunable.allocatedSizeBytes()
try SentrySDK.span?.setData(value: prunable.allocatedSizeBytes(), key: prunable.url.path)
cacheReclaimedBytes += try prunable.allocatedSizeBytes()
OpenTelemetry.instance.contextProvider.activeSpan?
.addEvent(name: "Pruned \(allocatedSizeBytes) bytes for \(prunable.url.path)")
try prunable.delete()
currentCacheUsedBytes = try prunables().map { try $0.allocatedSizeBytes() }.reduce(0, +)
}
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte);
OpenTelemetry.instance.contextProvider.activeSpan?
.addEvent(name: "Reclaimed \(initialCacheUsedBytes - currentCacheUsedBytes) 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)
}
}
+43 -10
View File
@@ -6,7 +6,7 @@ import Compression
struct Push: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Push a VM to a registry")
@Argument(help: "local or remote VM name")
@Argument(help: "local or remote VM name", completion: .custom(completeMachines))
var localName: String
@Argument(help: "remote VM name(s)")
@@ -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,16 +26,22 @@ 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() {
throw RuntimeError.VMIsRunning(localName)
}
// Parse remote names supplied by the user
let remoteNames = try remoteNames.map{
@@ -60,7 +69,7 @@ struct Push: AsyncParsableCommand {
let references = remoteNamesForRegistry.map{ $0.reference.value }
let pushedRemoteName: RemoteName
// If we're pushing a local OCI VM, check if points to an already existing registry manifest
// If we're pushing a cached remote image, check if it points to an existing registry manifest
// and if so, only upload manifests (without config, disk and NVRAM) to the user-specified references
if let remoteName = try? RemoteName(localName) {
pushedRemoteName = try await lightweightPushToRegistry(
@@ -69,16 +78,18 @@ struct Push: AsyncParsableCommand {
references: references
)
} else {
pushedRemoteName = try await localVMDir.pushToRegistry(
let pushedImage = try await localVMDir.pushToRegistry(
registry: registry,
references: references,
chunkSizeMb: chunkSize,
diskFormat: diskFormat
concurrency: concurrency,
labels: parseLabels()
)
pushedRemoteName = pushedImage.name
// Populate the local cache (if requested)
if populateCache {
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
try ociStorage.populate(pushedImage.name, from: localVMDir, manifest: pushedImage.manifest)
}
}
@@ -92,7 +103,7 @@ struct Push: AsyncParsableCommand {
}
func lightweightPushToRegistry(registry: Registry, remoteName: RemoteName, references: [String]) async throws -> RemoteName {
// Is the local OCI VM already present in the registry?
// Is the cached remote image already present in the registry?
let digest = try VMStorageOCI().digest(remoteName)
let (remoteManifest, _) = try await registry.pullManifest(reference: digest)
@@ -107,6 +118,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 {
+7 -4
View File
@@ -2,9 +2,9 @@ import ArgumentParser
import Foundation
struct Rename: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Rename a VM")
static var configuration = CommandConfiguration(abstract: "Rename a local VM")
@Argument(help: "VM name")
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
var name: String
@Argument(help: "new VM name")
@@ -17,10 +17,13 @@ struct Rename: AsyncParsableCommand {
}
func run() async throws {
let localStorage = VMStorageLocal()
let localStorage = try VMStorageLocal()
let lock = try FileLock(lockURL: Config().tartHomeDir)
try lock.lock()
defer { withExtendedLifetime(lock) {} }
if !localStorage.exists(name) {
throw ValidationError("failed to rename a non-existent VM: \(name)")
throw ValidationError("failed to rename a non-existent local VM: \(name)")
}
if localStorage.exists(newName) {
File diff suppressed because it is too large Load Diff
+60 -12
View File
@@ -1,10 +1,11 @@
import ArgumentParser
import Foundation
import Virtualization
struct Set: AsyncParsableCommand {
static var configuration = CommandConfiguration(commandName: "set", abstract: "Modify VM's configuration")
@Argument(help: "VM name")
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
var name: String
@Option(help: "Number of VM CPUs")
@@ -13,25 +14,39 @@ 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
#if arch(arm64)
@Flag(help: ArgumentHelp("Generate a new random serial number for the macOS VM."))
#endif
var randomSerial: Bool = false
@Option(help: ArgumentHelp("Replace the VM's disk contents with the disk contents at path.", valueName: "path"))
var disk: String?
@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?
func run() async throws {
let vmDir = try VMStorageLocal().open(name)
// Replacing disk.img would leave a stacked VM with both disk.img and
// overlay.asif, which is not a supported local layout. Reject before
// saving any other requested configuration changes.
if disk != nil, vmDir.isStackedVM {
throw ValidationError("--disk is not supported for VMs with a stacked disk")
}
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
if let cpu = cpu {
@@ -49,10 +64,31 @@ 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 {
vmConfig.platform = Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: oldPlatform.hardwareModel)
}
#endif
try vmConfig.save(toURL: vmDir.configURL)
if let disk = disk {
let temporaryDiskURL = try Config().tartTmpDir.appendingPathComponent("set-disk-\(UUID().uuidString)")
try FileManager.default.copyItem(atPath: disk, toPath: temporaryDiskURL.path())
_ = try FileManager.default.replaceItemAt(vmDir.diskURL, withItemAt: temporaryDiskURL)
}
if diskSize != nil {
try vmDir.resizeDisk(diskSize!)
}
@@ -61,12 +97,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,
)
}
}
+5 -5
View File
@@ -6,7 +6,7 @@ import SwiftDate
struct Stop: AsyncParsableCommand {
static var configuration = CommandConfiguration(commandName: "stop", abstract: "Stop a VM")
@Argument(help: "VM name")
@Argument(help: "VM name", completion: .custom(completeRunningMachines))
var name: String
@Option(name: [.short, .long], help: "Seconds to wait for graceful termination before forcefully terminating the VM")
@@ -15,12 +15,12 @@ struct Stop: AsyncParsableCommand {
func run() async throws {
let vmDir = try VMStorageLocal().open(name)
switch try vmDir.state() {
case "suspended":
case .Suspended:
try stopSuspended(vmDir)
case "running":
case .Running:
try await stopRunning(vmDir)
default:
return
case .Stopped:
throw RuntimeError.VMNotRunning(name)
}
}
+2 -2
View File
@@ -6,7 +6,7 @@ import SwiftDate
struct Suspend: AsyncParsableCommand {
static var configuration = CommandConfiguration(commandName: "suspend", abstract: "Suspend a VM")
@Argument(help: "VM name")
@Argument(help: "VM name", completion: .custom(completeRunningMachines))
var name: String
func run() async throws {
@@ -14,7 +14,7 @@ struct Suspend: AsyncParsableCommand {
let lock = try vmDir.lock()
// Find the VM's PID
var pid = try lock.pid()
let pid = try lock.pid()
if pid == 0 {
throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
}
+23 -2
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
@@ -32,7 +33,7 @@ struct Config {
continue
}
try FileManager.default.removeItem(at: entry)
try VMDirectory(baseURL: entry).removeFromDisk()
try lock.unlock()
}
@@ -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)")
}
}
}
}
+192
View File
@@ -0,0 +1,192 @@
import Foundation
enum ContentStoreError: Error, Equatable {
case invalidContentDigest(String)
case contentDigestMismatch(expected: String, actual: String)
}
/// Opaque content-addressed storage for immutable reconstructed files.
///
/// Stacked disks currently use it for complete base disks and published ASIF
/// overlays reconstructed from Tart disk chunks. OCI blob digests may differ
/// across registries, so the key is the full reconstructed-file digest.
struct ContentStore {
private static let digestAlgorithm = "sha256"
private static let digestPrefix = "\(digestAlgorithm):"
let baseURL: URL
private let digestDirectoryURL: URL
private let pruneLockURL: URL
init() throws {
try self.init(baseURL: Config().tartCacheDir.appendingPathComponent("content", isDirectory: true))
}
init(baseURL: URL) throws {
self.baseURL = baseURL
self.digestDirectoryURL = baseURL.appendingPathComponent(Self.digestAlgorithm, isDirectory: true)
self.pruneLockURL = baseURL.appendingPathComponent(".gc.lock")
try FileManager.default.createDirectory(at: digestDirectoryURL, withIntermediateDirectories: true)
if !FileManager.default.fileExists(atPath: pruneLockURL.path) {
_ = FileManager.default.createFile(atPath: pruneLockURL.path, contents: Data())
}
}
/// Serializes reference publication with the final reference check and
/// deletion of immutable cache entries across Tart processes.
func withPruneLock<T>(_ body: () throws -> T) throws -> T {
let lock = try FileLock(lockURL: pruneLockURL)
try lock.lock()
defer { try? lock.unlock() }
return try body()
}
/// Waits for any prune already scanning references to finish. After this
/// returns, later prune runs can see a reference the caller already wrote.
func synchronizePublishedReferences() throws {
try withPruneLock {}
}
func contentURL(for contentDigest: String) throws -> URL {
try contentURL(for: contentDigest, under: baseURL)
}
/// Returns the canonical path for a digest under an arbitrary content-store
/// root without creating directories or lock files.
func contentURL(for contentDigest: String, under baseURL: URL) throws -> URL {
let digestHex = try validatedDigestHex(contentDigest)
return baseURL
.appendingPathComponent(Self.digestAlgorithm, isDirectory: true)
.appendingPathComponent(digestHex)
}
func temporaryContentURL(for contentDigest: String) throws -> URL {
let targetURL = try contentURL(for: contentDigest)
return targetURL.deletingLastPathComponent().appendingPathComponent(".\(UUID().uuidString).tmp")
}
/// Returns a stable staging path so an interrupted registry pull can resume
/// reconstructing this content entry on a later attempt.
func resumableContentURL(for contentDigest: String) throws -> URL {
let targetURL = try contentURL(for: contentDigest)
return targetURL.deletingLastPathComponent().appendingPathComponent(".\(targetURL.lastPathComponent).partial")
}
/// Returns a stable lock file for serializing reconstruction of one content
/// entry. The file is intentionally retained; flock state lives on the file
/// descriptor and disappears when the owning process exits.
func lockURL(for contentDigest: String) throws -> URL {
let targetURL = try contentURL(for: contentDigest)
let lockURL = targetURL.deletingLastPathComponent().appendingPathComponent(".\(targetURL.lastPathComponent).lock")
if !FileManager.default.fileExists(atPath: lockURL.path) {
_ = FileManager.default.createFile(atPath: lockURL.path, contents: nil)
}
return lockURL
}
/// Returns an immutable digest-addressed entry without rereading it. Files
/// are verified when installed and when deciding whether a pull is a cache
/// hit; normal clone/run/push paths trust the store like Tart's disk.img.
func contentURLIfPresent(for contentDigest: String) throws -> URL? {
let url = try contentURL(for: contentDigest)
guard FileManager.default.fileExists(atPath: url.path) else {
return nil
}
try url.updateAccessDate()
return url
}
/// Returns a validated cache hit. Corrupt files are treated as misses so a
/// later pull can safely rebuild them.
func existingContentURL(for contentDigest: String) throws -> URL? {
guard let url = try contentURLIfPresent(for: contentDigest) else {
return nil
}
guard try Digest.hash(url) == contentDigest else {
return nil
}
return url
}
/// Returns immutable content files that no retained cached image or local VM
/// references. Callers may prune these like other cache entries.
func prunables(excluding referencedContentDigests: Swift.Set<String>) throws -> [URL] {
guard let enumerator = FileManager.default.enumerator(
at: digestDirectoryURL,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsSubdirectoryDescendants]
) else {
return []
}
return try enumerator.compactMap { element in
guard let url = element as? URL,
try url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true else {
return nil
}
let contentDigest = "\(Self.digestPrefix)\(url.lastPathComponent)"
guard (try? validatedDigestHex(contentDigest)) != nil,
!referencedContentDigests.contains(contentDigest) else {
return nil
}
return url
}
}
/// Move a fully reconstructed temporary file into the cache after verifying
/// its semantic identity. The caller should create the temporary file with
/// temporaryContentURL(for:) or resumableContentURL(for:) so rename stays on
/// the same filesystem.
func install(_ temporaryURL: URL, contentDigest: String) throws -> URL {
let actualDigest = try Digest.hash(temporaryURL)
guard actualDigest == contentDigest else {
throw ContentStoreError.contentDigestMismatch(expected: contentDigest, actual: actualDigest)
}
let targetURL = try contentURL(for: contentDigest)
let lock = try FileLock(lockURL: baseURL)
try lock.lock()
defer { try? lock.unlock() }
if let existingURL = try existingContentURL(for: contentDigest) {
try? FileManager.default.removeItem(at: temporaryURL)
return existingURL
}
if FileManager.default.fileExists(atPath: targetURL.path) {
_ = try FileManager.default.replaceItemAt(targetURL, withItemAt: temporaryURL)
} else {
try FileManager.default.moveItem(at: temporaryURL, to: targetURL)
}
return targetURL
}
private func validatedDigestHex(_ contentDigest: String) throws -> String {
guard contentDigest.hasPrefix(Self.digestPrefix) else {
throw ContentStoreError.invalidContentDigest(contentDigest)
}
let digestHex = String(contentDigest.dropFirst(Self.digestPrefix.count))
let isHex = digestHex.allSatisfy { $0.isHexDigit && !$0.isUppercase }
guard digestHex.count == 64, isHex else {
throw ContentStoreError.invalidContentDigest(contentDigest)
}
return digestHex
}
}
+107
View File
@@ -0,0 +1,107 @@
import Foundation
import Network
import os.log
import NIO
import NIOPosix
@available(macOS 14, *)
class ControlSocket {
typealias ServerChannel = NIOAsyncChannel<NIOAsyncChannel<ByteBuffer, ByteBuffer>, Never>
let controlSocketURL: URL
let vmPort: UInt32
let eventLoopGroup: MultiThreadedEventLoopGroup
let serverChannel: ServerChannel
let logger: os.Logger = os.Logger(subsystem: "org.cirruslabs.tart.control-socket", category: "network")
init(_ controlSocketURL: URL, vmPort: UInt32 = 8080) async throws {
self.controlSocketURL = controlSocketURL
self.vmPort = vmPort
let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
self.eventLoopGroup = eventLoopGroup
// 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(at: controlSocketURL)
// 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.absoluteURL.path(percentEncoded: false))
}
do {
self.serverChannel = try await ServerBootstrap(group: eventLoopGroup)
.bind(unixDomainSocketPath: controlSocketURL.relativePath) { childChannel in
childChannel.eventLoop.makeCompletedFuture {
return try NIOAsyncChannel<ByteBuffer, ByteBuffer>(
wrappingChannelSynchronously: childChannel
)
}
}
} catch {
try? await eventLoopGroup.shutdownGracefully()
throw error
}
}
func run() async throws {
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,
@@ -41,7 +43,7 @@ class KeychainCredentialsProvider: CredentialsProvider {
kSecAttrLabel as String: "Tart Credentials",
]
let value: [String: Any] = [kSecAttrAccount as String: user,
kSecValueData as String: passwordData,
kSecValueData as String: passwordData as Any,
]
let status = SecItemCopyMatching(key as CFDictionary, nil)
@@ -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 }
}
}
+304
View File
@@ -0,0 +1,304 @@
import Foundation
import Virtualization
#if canImport(DiskImageKit)
import DiskImageKit
#endif
/// The logical block layout exposed by a disk image.
struct DiskImageBlockLayout {
let blockSize: UInt64
let blockCount: UInt64
}
enum DiskImageStackError: Error, Equatable, CustomStringConvertible {
case unavailable
case writableOverlayAlreadyExists(URL)
case writableOverlayMissing(URL)
case invalidBlockLayout(String)
case invalidDiskImage(URL, String)
var description: String {
switch self {
case .unavailable:
"stacked disks require DiskImageKit on macOS 27 or newer"
case .writableOverlayAlreadyExists(let url):
"writable overlay already exists: \(url.path)"
case .writableOverlayMissing(let url):
"writable overlay is missing: \(url.path)"
case .invalidBlockLayout(let reason):
reason
case .invalidDiskImage(let url, let reason):
"\(reason): \(url.path)"
}
}
}
struct DiskImageStack {
/// DiskImageKit-ready paths and block layout after Tart disk chunks have been
/// reconstructed into complete immutable files. The writable overlay stays
/// private to one VM.
let baseURL: URL
let baseFormat: DiskImageFormat
let immutableOverlayURLs: [URL]
let writableOverlayURL: URL
let blockSize: UInt64
let blockCount: UInt64
static var isSupported: Bool {
#if canImport(DiskImageKit)
if #available(macOS 27.0, *) {
return true
}
#endif
return false
}
static func requireSupport() throws {
guard isSupported else {
throw DiskImageStackError.unavailable
}
}
/// Reads a disk image's current block layout without resolving or validating a
/// whole stack. This is used for the VM's private writable overlay, whose
/// size may be newer than the pinned immutable parent manifest.
static func diskImageBlockLayout(at url: URL) throws -> DiskImageBlockLayout {
#if canImport(DiskImageKit)
if #available(macOS 27.0, *) {
let image = try DiskImage(opening: .open(url: url, mode: .readOnly))
return DiskImageBlockLayout(
blockSize: UInt64(image.blockSize.rawValue),
blockCount: UInt64(image.blockCount)
)
}
#endif
throw DiskImageStackError.unavailable
}
static func baseBlockLayout(
at url: URL,
expectedFormat: DiskImageFormat
) throws -> DiskImageBlockLayout {
#if canImport(DiskImageKit)
if #available(macOS 27.0, *) {
let image = try DiskImage(opening: .open(url: url, mode: .readOnly))
try validateBase(image, at: url, expectedFormat: expectedFormat)
return DiskImageBlockLayout(
blockSize: UInt64(image.blockSize.rawValue),
blockCount: UInt64(image.blockCount)
)
}
#endif
throw DiskImageStackError.unavailable
}
func createWritableOverlay() throws {
#if canImport(DiskImageKit)
if #available(macOS 27.0, *) {
try createWritableOverlayWithDiskImageKit()
return
}
#endif
throw DiskImageStackError.unavailable
}
func copyWritableOverlay(to destinationURL: URL) throws {
guard !FileManager.default.fileExists(atPath: destinationURL.path) else {
throw DiskImageStackError.writableOverlayAlreadyExists(destinationURL)
}
try FileManager.default.copyItem(at: writableOverlayURL, to: destinationURL)
}
func makeAttachment(
readOnly: Bool = false,
cachingMode: VZDiskImageCachingMode = .automatic,
synchronizationMode: VZDiskImageSynchronizationMode = .full
) throws -> VZStorageDeviceAttachment {
#if canImport(DiskImageKit)
if #available(macOS 27.0, *) {
return try attachmentWithDiskImageKit(
readOnly: readOnly,
cachingMode: cachingMode,
synchronizationMode: synchronizationMode
)
}
#endif
throw DiskImageStackError.unavailable
}
func growWritableOverlay(toBlockCount blockCount: UInt64) throws {
#if canImport(DiskImageKit)
if #available(macOS 27.0, *) {
try growWritableOverlayWithDiskImageKit(toBlockCount: blockCount)
return
}
#endif
throw DiskImageStackError.unavailable
}
#if canImport(DiskImageKit)
@available(macOS 27.0, *)
private func createWritableOverlayWithDiskImageKit() throws {
guard !FileManager.default.fileExists(atPath: writableOverlayURL.path) else {
throw DiskImageStackError.writableOverlayAlreadyExists(writableOverlayURL)
}
let parent = try validatedParentImage()
let stackedImage = try parent.appending(.asifLayer(url: writableOverlayURL, type: .overlay))
try validateAppendedOverlay(stackedImage, at: writableOverlayURL)
}
@available(macOS 27.0, *)
private func attachmentWithDiskImageKit(
readOnly: Bool,
cachingMode: VZDiskImageCachingMode,
synchronizationMode: VZDiskImageSynchronizationMode
) throws -> VZDiskImageStorageDeviceAttachment {
guard FileManager.default.fileExists(atPath: writableOverlayURL.path) else {
throw DiskImageStackError.writableOverlayMissing(writableOverlayURL)
}
let parent = try validatedParentImage()
let writableOverlay = try openOverlay(
at: writableOverlayURL,
mode: readOnly ? .readOnly : .readWrite
)
let stackedImage = try append(writableOverlay, to: parent, at: writableOverlayURL)
try validateAppendedOverlay(stackedImage, at: writableOverlayURL)
return try VZDiskImageStorageDeviceAttachment(
diskImage: stackedImage,
cachingMode: cachingMode,
synchronizationMode: synchronizationMode
)
}
@available(macOS 27.0, *)
private func growWritableOverlayWithDiskImageKit(toBlockCount blockCount: UInt64) throws {
guard blockCount > 0, let desiredBlockCount = Int(exactly: blockCount) else {
throw DiskImageStackError.invalidBlockLayout("invalid stacked disk block count \(blockCount)")
}
let parent = try validatedParentImage()
let overlay = try openOverlay(
at: writableOverlayURL,
mode: .readWrite
)
let currentBlockCount = overlay.blockCount
let stackedImage = try append(overlay, to: parent, at: writableOverlayURL)
try validateAppendedOverlay(stackedImage, at: writableOverlayURL)
guard desiredBlockCount >= currentBlockCount else {
throw DiskImageStackError.invalidDiskImage(writableOverlayURL, "ASIF overlay block count shrinks the stacked disk")
}
guard let writableOverlay = stackedImage.layers.last else {
throw DiskImageStackError.invalidDiskImage(writableOverlayURL, "disk image must be an ASIF overlay")
}
if desiredBlockCount > currentBlockCount {
try writableOverlay.truncate(blockCount: desiredBlockCount)
}
}
@available(macOS 27.0, *)
private func validatedParentImage() throws -> DiskImage {
let expectedBlockSize = try diskImageBlockSize(blockSize)
guard blockCount > 0, let expectedBlockCount = Int(exactly: blockCount) else {
throw DiskImageStackError.invalidBlockLayout("invalid stacked disk block count \(blockCount)")
}
let baseImage = try DiskImage(opening: .open(url: baseURL, mode: .readOnly))
try Self.validateBase(baseImage, at: baseURL, expectedFormat: baseFormat)
var image = baseImage
for overlayURL in immutableOverlayURLs {
let openedOverlay = try openOverlay(
at: overlayURL,
mode: .readOnly
)
let stackedImage = try append(openedOverlay, to: image, at: overlayURL)
try validateAppendedOverlay(stackedImage, at: overlayURL)
image = stackedImage
}
guard image.blockSize == expectedBlockSize else {
throw DiskImageStackError.invalidBlockLayout("immutable disk stack does not match manifest block size")
}
guard image.blockCount == expectedBlockCount else {
throw DiskImageStackError.invalidBlockLayout("immutable disk stack does not match manifest block count")
}
return image
}
@available(macOS 27.0, *)
private static func validateBase(
_ image: DiskImage,
at url: URL,
expectedFormat: DiskImageFormat
) throws {
let matchesFormat = switch expectedFormat {
case .raw:
image.format == .raw
case .asif:
image.format == .asif
}
guard matchesFormat else {
throw DiskImageStackError.invalidDiskImage(url, "base disk format does not match")
}
guard image.layerType == nil, image.parentUUID == nil else {
throw DiskImageStackError.invalidDiskImage(url, "base disk must not be an overlay")
}
if expectedFormat == .asif && image.layerUUID == nil {
throw DiskImageStackError.invalidDiskImage(url, "ASIF base disk is missing a UUID")
}
}
@available(macOS 27.0, *)
private func openOverlay(
at url: URL,
mode: OpenConfiguration.Mode
) throws -> DiskImage {
let image = try DiskImage(opening: .open(url: url, mode: mode))
guard image.format == .asif else {
throw DiskImageStackError.invalidDiskImage(url, "overlay must use ASIF format")
}
return image
}
@available(macOS 27.0, *)
private func append(_ overlay: DiskImage, to parent: DiskImage, at url: URL) throws -> any StackedImage {
do {
return try parent.appending(overlay)
} catch is IncompatibleStackingError {
throw DiskImageStackError.invalidDiskImage(url, "ASIF overlay is incompatible with its parent")
}
}
@available(macOS 27.0, *)
private func validateAppendedOverlay(_ image: any StackedImage, at url: URL) throws {
guard image.layers.last?.layerType == .overlay else {
throw DiskImageStackError.invalidDiskImage(url, "disk image must be an ASIF overlay")
}
}
@available(macOS 27.0, *)
private func diskImageBlockSize(_ value: UInt64) throws -> DiskImage.BlockSize {
guard let intValue = Int(exactly: value), let blockSize = DiskImage.BlockSize(rawValue: intValue) else {
throw DiskImageStackError.invalidBlockLayout("unsupported stacked disk block size \(value)")
}
return blockSize
}
#endif
}
+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 -51
View File
@@ -1,56 +1,6 @@
import Foundation
import AsyncAlgorithms
fileprivate let urlSession = createURLSession()
class Fetcher {
static func fetch(_ request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
if viaFile {
return try await fetchViaFile(request)
}
return try await fetchViaMemory(request)
}
private static func fetchViaMemory(_ request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
let dataCh = AsyncThrowingChannel<Data, Error>()
let (data, response) = try await urlSession.data(for: request)
Task {
await dataCh.send(data)
dataCh.finish()
}
return (dataCh, response as! HTTPURLResponse)
}
private static func fetchViaFile(_ request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
let dataCh = AsyncThrowingChannel<Data, Error>()
let (fileURL, response) = try await urlSession.download(for: request)
// 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
@@ -64,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
}
}
}
@@ -0,0 +1,26 @@
import Foundation
struct HumanReadableByteCount: Encodable, CustomStringConvertible {
private let byteCount: Int
private let jsonValue: any Encodable
init<JSONValue: Encodable>(_ byteCount: Int, encodedAs: (Int) -> JSONValue) {
self.byteCount = byteCount
self.jsonValue = encodedAs(byteCount)
}
var description: String {
let formatter = MeasurementFormatter()
formatter.unitOptions = .naturalScale
formatter.unitStyle = .medium
formatter.numberFormatter.maximumFractionDigits = 0
return formatter.string(
from: Measurement(value: Double(byteCount), unit: UnitInformationStorage.bytes)
)
}
func encode(to encoder: Encoder) throws {
try jsonValue.encode(to: encoder)
}
}
+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
}
}
+57
View File
@@ -0,0 +1,57 @@
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: 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
// mmap(2) the disk that contains the layers from the manifest
self.mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
// Record the ranges of the disk layers listed in the manifest
var offset: UInt64 = 0
for layer in manifest.layers.filter({ $0.mediaType == diskV2MediaType }) {
guard let uncompressedSize = layer.uncompressedSize() else {
return nil
}
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 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]
}
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)
}
}
}
+2 -1
View File
@@ -1,7 +1,8 @@
import Virtualization
import Semaphore
protocol Network {
func attachments() -> [VZNetworkDeviceAttachment]
func run(_ sema: DispatchSemaphore) throws
func run(_ sema: AsyncSemaphore) throws
func stop() async throws
}
+2 -1
View File
@@ -1,4 +1,5 @@
import Foundation
import Semaphore
import Virtualization
class NetworkBridged: Network {
@@ -12,7 +13,7 @@ class NetworkBridged: Network {
interfaces.map { VZBridgedNetworkDeviceAttachment(interface: $0) }
}
func run(_ sema: DispatchSemaphore) throws {
func run(_ sema: AsyncSemaphore) throws {
// no-op, only used for Softnet
}
+2 -1
View File
@@ -1,4 +1,5 @@
import Foundation
import Semaphore
import Virtualization
class NetworkShared: Network {
@@ -6,7 +7,7 @@ class NetworkShared: Network {
[VZNATNetworkDeviceAttachment()]
}
func run(_ sema: DispatchSemaphore) throws {
func run(_ sema: AsyncSemaphore) throws {
// no-op, only used for Softnet
}
+55 -4
View File
@@ -1,7 +1,8 @@
import Foundation
import Virtualization
import Atomics
import Foundation
import Semaphore
import System
import Virtualization
enum SoftnetError: Error {
case InitializationFailed(why: String)
@@ -10,12 +11,22 @@ enum SoftnetError: Error {
class Softnet: Network {
private let process = Process()
private var controlFileHandle: FileHandle?
private var monitorTask: Task<Void, Error>? = nil
private let monitorTaskFinished = ManagedAtomic<Bool>(false)
let vmFD: Int32
init(vmMACAddress: String, extraArguments: [String] = []) throws {
init(vmMACAddress: String, extraArguments: [String] = [], controlFD: Int32? = nil) throws {
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)
@@ -32,6 +43,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 {
@@ -44,7 +93,9 @@ class Softnet: Network {
return executableURL
}
func run(_ sema: DispatchSemaphore) throws {
func run(_ sema: AsyncSemaphore) throws {
defer { try? controlFileHandle?.close() }
try process.run()
monitorTask = Task {
+45 -7
View File
@@ -7,6 +7,8 @@ enum DigestError: Error {
}
class Digest {
private static let fileBufferSize = 4 * 1024 * 1024
var hash: SHA256 = SHA256()
func update(_ data: Data) {
@@ -22,7 +24,10 @@ class Digest {
}
static func hash(_ url: URL) throws -> String {
hash(try Data(contentsOf: url))
let file = try FileHandle(forReadingFrom: url)
defer { try? file.close() }
return try hashContents(from: file)
}
static func hash(_ url: URL, offset: UInt64, size: UInt64) throws -> String {
@@ -36,20 +41,53 @@ class Digest {
throw DigestError.InvalidOffset
}
if (offset + size) > fileSize {
if size > fileSize - offset {
throw DigestError.InvalidSize
}
// Read a chunk of size ``size`` at offset ``offset``
// and calculate it's digest
// Read the requested range incrementally and calculate its digest.
let fh = try FileHandle(forReadingFrom: url)
defer { try! fh.close() }
defer { try? fh.close() }
try fh.seek(toOffset: offset)
let data = try fh.read(upToCount: Int(size))!
return try hashContents(from: fh, size: size)
}
return hash(data)
/// Streams a file into SHA-256 while keeping Foundation's temporary read
/// buffers scoped to one chunk.
private static func hashContents(from file: FileHandle, size: UInt64? = nil) throws -> String {
let digest = Digest()
var remaining = size
while remaining.map({ $0 > 0 }) ?? true {
let didRead = try autoreleasepool { () throws -> Bool in
let count = remaining.map {
Int(min(UInt64(fileBufferSize), $0))
} ?? fileBufferSize
guard let data = try file.read(upToCount: count), !data.isEmpty else {
if remaining != nil {
throw DigestError.InvalidSize
}
return false
}
digest.update(data)
if let bytesRemaining = remaining {
remaining = bytesRemaining - UInt64(data.count)
}
return true
}
if !didRead {
break
}
}
return digest.finalize()
}
}
+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) async throws
static func push(diskURL: URL, mediaType: String, 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) 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()
}
}
+198 -86
View File
@@ -1,48 +1,111 @@
import Foundation
import Compression
import System
import Retry
class DiskV2: Disk {
private static let bufferSizeBytes = 4 * 1024 * 1024
private static let layerLimitBytes = 500 * 1000 * 1000
private static let holeGranularityBytes = 64 * 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,
mediaType: String,
registry: Registry,
chunkSizeMb: Int,
concurrency: UInt,
progress: Progress
) async throws -> [OCIManifestLayer] {
var pushedLayers: [(index: Int, pushedLayer: OCIManifestLayer)] = []
// Open the disk file
var mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
// Compress the disk file as multiple individually decompressible streams,
// each equal ``Self.layerLimitBytes`` bytes or slightly larger due to the
// internal compressor's buffer
var offset: UInt64 = 0
// each equal ``Self.layerLimitBytes`` bytes or less due to LZ4 compression
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))
}
}
while let (compressedData, uncompressedSize, uncompressedDigest) = try compressNextLayerOfLimitBytesOrMore(mappedDisk: mappedDisk, offset: offset) {
offset += uncompressedSize
// Launch a disk layer pushing task
group.addTask {
let compressedData = try (data as NSData).compressed(using: .lz4) as Data
let compressedDataDigest = Digest.hash(compressedData)
let layerDigest = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb)
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...")
pushedLayers.append(OCIManifestLayer(
mediaType: diskV2MediaType,
size: compressedData.count,
digest: layerDigest,
uncompressedSize: uncompressedSize,
uncompressedContentDigest: uncompressedDigest
))
return .retry
}
// Update progress using a relative value
progress.completedUnitCount += Int64(uncompressedSize)
return .throw
}
// Update progress using a relative value
progress.completedUnitCount += Int64(data.count)
return (index, OCIManifestLayer(
mediaType: mediaType,
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) 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
@@ -62,6 +125,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
@@ -86,29 +158,52 @@ 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 {
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
}
}
// Open the disk file for writing
let disk = try FileHandle(forWritingTo: diskURL)
// 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)
return
}
// Open the disk file
let disk = try FileHandle(forWritingTo: diskURL)
// A zero chunk for faster than byte-by-byte comparisons
//
// Assumes that the other Data(...) is equal in size, but it's fine to get a false-negative
// on the last block since it costs only 64 KiB of excess data per 500 MB layer.
//
// Some simple benchmarks ("sync && sudo purge" command was used to negate the disk caching effects):
// +--------------------------------------+---------------------------------------------------+
// | Operation | time(1) result |
// +--------------------------------------+---------------------------------------------------+
// | Data(...) == zeroChunk | 2.16s user 11.71s system 73% cpu 18.928 total |
// | Data(...).contains(where: {$0 != 0}) | 603.68s user 12.97s system 99% cpu 10:22.85 total |
// +--------------------------------------+---------------------------------------------------+
let zeroChunk = Data(count: holeGranularityBytes)
var diskWritingOffset = diskWritingOffset
// Pull and decompress a single layer into the specific offset on disk
@@ -117,27 +212,38 @@ class DiskV2: Disk {
return
}
for chunk in data.chunks(ofCount: holeGranularityBytes) {
// Only write chunks that are not zero
if chunk != zeroChunk {
try disk.seek(toOffset: diskWritingOffset)
disk.write(chunk)
}
diskWritingOffset += UInt64(chunk.count)
}
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
@@ -145,44 +251,50 @@ class DiskV2: Disk {
}
}
private static func compressNextLayerOfLimitBytesOrMore(mappedDisk: Data, offset: UInt64) throws -> (Data, UInt64, String)? {
var compressedData = Data()
var bytesRead: UInt64 = 0
let digest = Digest()
private static func zeroSkippingWrite(_ disk: FileHandle, _ rdisk: FileHandle?, _ fsBlockSize: UInt64, _ offset: UInt64, _ data: Data) throws -> UInt64 {
var offset = offset
// Create a compressing filter that we will terminate upon
// reaching ``Self.layerLimitBytes`` of compressed data
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: bufferSizeBytes) { (length: Int) -> Data? in
if compressedData.count >= Self.layerLimitBytes {
return nil
for chunk in data.chunks(ofCount: holeGranularityBytes) {
// 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
}
let readFromByte = Int(offset + bytesRead)
let numBytesToRead = min(mappedDisk.count - readFromByte, bufferSizeBytes)
if numBytesToRead == 0 {
return nil
// 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)
try disk.write(contentsOf: chunk)
}
let uncompressedChunk = mappedDisk.subdata(in: readFromByte ..< (readFromByte + numBytesToRead))
bytesRead += UInt64(uncompressedChunk.count)
digest.update(uncompressedChunk)
return uncompressedChunk
offset += UInt64(chunk.count)
}
// Retrieve compressed data chunks, but normally no more than ``Self.layerLimitBytes`` bytes
while let compressedChunk = try compressingFilter.readData(ofLength: Self.bufferSizeBytes) {
compressedData.append(compressedChunk)
}
// Nothing was read this time from the disk,
// signal that to the consumer
if bytesRead == 0 {
return nil
}
return (compressedData, bytesRead, digest.finalize())
return offset
}
}
+182 -2
View File
@@ -6,17 +6,66 @@ 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 asifOverlayMediaType = "application/vnd.cirruslabs.tart.disk.asif.overlay.v1"
let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1"
// Manifest annotations
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time"
let diskBlockSizeAnnotation = "org.cirruslabs.tart.disk.block-size"
// 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"
let diskFileContentDigestAnnotation = "org.cirruslabs.tart.disk-file-content-digest"
let diskFileChunkCountAnnotation = "org.cirruslabs.tart.disk-file-chunk-count"
/// The OCI-layer descriptors whose Tart disk chunks reconstruct one complete
/// base disk or ASIF overlay.
struct TartDiskFileGroup: Equatable {
enum Kind: Equatable {
case base
case asifOverlay
}
var kind: Kind
var chunks: [OCIManifestLayer]
/// Whole reconstructed-file digest. Existing flat manifests do not have
/// this until a macOS 27 clone normalizes its local manifest copy.
var contentDigest: String?
/// Expected size of the complete disk file reconstructed from these chunks.
func uncompressedSize() -> UInt64? {
var result: UInt64 = 0
for chunk in chunks {
guard let size = chunk.uncompressedSize() else {
return nil
}
let addition = result.addingReportingOverflow(size)
guard !addition.overflow else {
return nil
}
result = addition.partialValue
}
return result
}
}
enum TartDiskRepresentation: Equatable {
case flat(base: TartDiskFileGroup)
case stacked(base: TartDiskFileGroup, overlays: [TartDiskFileGroup])
}
enum OCIManifestValidationError: Error, Equatable {
case invalidLayout(String)
case invalidDiskMetadata(String)
}
struct OCIManifest: Codable, Equatable {
var schemaVersion: Int = 2
@@ -61,11 +110,126 @@ struct OCIManifest: Codable, Equatable {
return UInt64(value)
}
/// Parse Tart's canonical `config -> disk descriptors -> NVRAM` order.
/// A stacked image has a leading `disk.v2` base run followed by one or more
/// contiguous ASIF overlay chunk groups.
func tartDiskRepresentation() throws -> TartDiskRepresentation {
guard layers.filter({ $0.mediaType == configMediaType }).count == 1 else {
throw OCIManifestValidationError.invalidLayout("manifest must contain exactly one Tart config descriptor")
}
guard layers.filter({ $0.mediaType == nvramMediaType }).count == 1 else {
throw OCIManifestValidationError.invalidLayout("manifest must contain exactly one NVRAM descriptor")
}
guard layers.first?.mediaType == configMediaType,
layers.last?.mediaType == nvramMediaType else {
throw OCIManifestValidationError.invalidLayout("descriptors must be ordered as config, disk chunks, then NVRAM")
}
let diskDescriptors = Array(layers.dropFirst().dropLast())
guard !diskDescriptors.isEmpty else {
throw OCIManifestValidationError.invalidLayout("manifest has no disk chunks")
}
let baseChunkCount = diskDescriptors.prefix { $0.mediaType == diskV2MediaType }.count
guard baseChunkCount > 0 else {
throw OCIManifestValidationError.invalidLayout("disk chunks must start with a disk.v2 base")
}
let baseChunks = Array(diskDescriptors.prefix(baseChunkCount))
try validateChunkMetadata(baseChunks)
guard baseChunks.first?.diskFileChunkCount() == nil,
baseChunks.dropFirst().allSatisfy({
$0.diskFileContentDigest() == nil && $0.diskFileChunkCount() == nil
}) else {
throw OCIManifestValidationError.invalidDiskMetadata("base disk metadata must appear only on its first chunk")
}
let base = TartDiskFileGroup(
kind: .base,
chunks: baseChunks,
contentDigest: baseChunks.first?.diskFileContentDigest()
)
guard baseChunkCount < diskDescriptors.count else {
return .flat(base: base)
}
guard base.contentDigest != nil else {
throw OCIManifestValidationError.invalidDiskMetadata("a stacked base disk needs a whole-file content digest")
}
var overlays: [TartDiskFileGroup] = []
var index = baseChunkCount
while index < diskDescriptors.count {
let first = diskDescriptors[index]
guard first.mediaType == asifOverlayMediaType else {
throw OCIManifestValidationError.invalidLayout("unsupported disk chunk media type: \(first.mediaType)")
}
guard let contentDigest = first.diskFileContentDigest(),
let chunkCount = first.diskFileChunkCount() else {
throw OCIManifestValidationError.invalidDiskMetadata("an ASIF overlay needs a content digest and chunk count")
}
guard chunkCount > 0, index + chunkCount <= diskDescriptors.count else {
throw OCIManifestValidationError.invalidDiskMetadata("ASIF overlay chunk count is invalid")
}
let chunks = Array(diskDescriptors[index..<(index + chunkCount)])
guard chunks.allSatisfy({ $0.mediaType == asifOverlayMediaType }) else {
throw OCIManifestValidationError.invalidLayout("ASIF overlay chunks must be contiguous")
}
guard chunks.dropFirst().allSatisfy({ $0.diskFileContentDigest() == nil && $0.diskFileChunkCount() == nil }) else {
throw OCIManifestValidationError.invalidDiskMetadata("ASIF overlay metadata must appear only on its first chunk")
}
try validateChunkMetadata(chunks)
overlays.append(TartDiskFileGroup(kind: .asifOverlay, chunks: chunks, contentDigest: contentDigest))
index += chunkCount
}
return .stacked(base: base, overlays: overlays)
}
/// Returns content-store digests needed to reconstruct this disk stack.
func diskContentDigests() throws -> [String] {
switch try tartDiskRepresentation() {
case .flat(let base):
return base.contentDigest.map { [$0] } ?? []
case .stacked(let base, let overlays):
return ([base] + overlays).compactMap(\.contentDigest)
}
}
private func validateChunkMetadata(_ chunks: [OCIManifestLayer]) throws {
guard chunks.allSatisfy({ $0.uncompressedSize() != nil && $0.uncompressedContentDigest() != nil }) else {
throw OCIManifestValidationError.invalidDiskMetadata("disk chunks need uncompressed size and content digest")
}
}
func diskBlockSize() -> UInt64? {
annotations?[diskBlockSizeAnnotation].flatMap(UInt64.init)
}
func diskBlockCount() -> UInt64? {
guard let diskSize = uncompressedDiskSize(),
let blockSize = diskBlockSize(),
blockSize > 0,
diskSize.isMultiple(of: blockSize) else {
return nil
}
return diskSize / blockSize
}
}
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)
@@ -78,7 +242,7 @@ struct OCIManifestConfig: Codable, Equatable {
var digest: String
}
struct OCIManifestLayer: Codable, Equatable {
struct OCIManifestLayer: Codable, Equatable, Hashable {
var mediaType: String
var size: Int
var digest: String
@@ -113,6 +277,22 @@ struct OCIManifestLayer: Codable, Equatable {
func uncompressedContentDigest() -> String? {
annotations?[uncompressedContentDigestAnnotation]
}
func diskFileContentDigest() -> String? {
annotations?[diskFileContentDigestAnnotation]
}
func diskFileChunkCount() -> Int? {
annotations?[diskFileChunkCountAnnotation].flatMap(Int.init)
}
static func == (lhs: Self, rhs: Self) -> Bool {
return lhs.digest == rhs.digest
}
func hash(into hasher: inout Hasher) {
hasher.combine(digest)
}
}
struct Descriptor: Equatable {
@@ -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 {
+84 -32
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
@@ -42,9 +56,6 @@ extension AsyncThrowingChannel<Data, Error> {
}
struct TokenResponse: Decodable, Authentication {
let defaultIssuedAt = Date()
let defaultExpiresIn = 60
var token: String?
var accessToken: String?
var expiresIn: Int?
@@ -66,7 +77,8 @@ struct TokenResponse: Decodable, Authentication {
return dateFormatter.date(from: dateString) ?? Date()
}
let response = try decoder.decode(TokenResponse.self, from: fromData)
var response = try decoder.decode(TokenResponse.self, from: fromData)
response.issuedAt = response.issuedAt ?? Date()
guard response.token != nil || response.accessToken != nil else {
throw DecodingError.keyNotFound(CodingKeys.token, .init(codingPath: [], debugDescription: "Missing token or access_token. One must be present."))
@@ -85,7 +97,7 @@ struct TokenResponse: Decodable, Authentication {
//
// [1]: https://docs.docker.com/registry/spec/auth/token/#requesting-a-token
(issuedAt ?? defaultIssuedAt) + TimeInterval(expiresIn ?? defaultExpiresIn)
(issuedAt ?? Date()) + TimeInterval(expiresIn ?? 60)
}
}
@@ -114,11 +126,11 @@ class Registry {
return host
}
init(urlComponents: URLComponents,
init(baseURL: URL,
namespace: String,
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
) throws {
baseURL = urlComponents.url!
self.baseURL = baseURL
self.namespace = namespace
self.credentialsProviders = credentialsProviders
}
@@ -132,7 +144,17 @@ class Registry {
let proto = insecure ? "http" : "https"
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
try self.init(urlComponents: baseURLComponents, namespace: namespace, credentialsProviders: credentialsProviders)
guard let baseURL = baseURLComponents.url else {
var hint = ""
if host.hasPrefix("http://") || host.hasPrefix("https://") {
hint += ", make sure that it doesn't start with http:// or https://"
}
throw RuntimeError.ImproperlyFormattedHost(host, hint)
}
try self.init(baseURL: baseURL, namespace: namespace, credentialsProviders: credentialsProviders)
}
func ping() async throws {
@@ -150,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)
@@ -161,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)
@@ -181,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
@@ -208,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
}
@@ -231,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
@@ -241,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)
}
@@ -284,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 {
@@ -304,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)
@@ -369,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))
@@ -383,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)")
}
}
+12 -3
View File
@@ -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)
@@ -123,6 +127,11 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
[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()]
+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()
}
}
+2 -1
View File
@@ -3,10 +3,11 @@ 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 {
+148 -69
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,91 +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) }
// 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
@@ -122,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
}
@@ -0,0 +1,28 @@
import Foundation
fileprivate func normalizeName(_ name: String) -> String {
// Colons are misinterpreted by Zsh completion
return name.replacingOccurrences(of: ":", with: "\\:")
}
func completeMachines(_ arguments: [String], _ argumentIdx: Int, _ argumentPrefix: String) -> [String] {
let localVMs = (try? VMStorageLocal().list().map { name, _ in
normalizeName(name)
}) ?? []
let ociVMs = (try? VMStorageOCI().list().map { name, _, _ in
normalizeName(name)
}) ?? []
return (localVMs + ociVMs)
}
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], _ argumentIdx: Int, _ argumentPrefix: String) -> [String] {
let localVMs = (try? VMStorageLocal().list()) ?? []
return localVMs
.filter { _, vmDir in (try? vmDir.state() == .Running) ?? false}
.map { name, _ in normalizeName(name) }
}
+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
}
}

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