Compare commits

..
Author SHA1 Message Date
Fedor Korotkov 15754bcc8d Add FD-based network mode 2026-02-25 09:17:51 -05:00
10 changed files with 399 additions and 79 deletions
+147 -47
View File
@@ -1,64 +1,164 @@
use_compute_credits: true
task:
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:
- 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
- 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
task:
name: Markdown Lint
only_if: $CIRRUS_BRANCH != 'gh-pages' && changesInclude('**.md')
container:
image: node:latest
install_script: npm install -g markdownlint-cli
lint_script: markdownlint --config=docs/.markdownlint.yml docs/
task:
name: Lint
alias: lint
macos_instance:
image: ghcr.io/cirruslabs/macos-runner:sequoia
lint_script:
- swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json .
always:
swiftformat_report_artifacts:
path: swiftformat.json
format: swiftformat
task:
only_if: $CIRRUS_TAG == ''
env:
matrix:
BUILD_ARCH: arm64
BUILD_ARCH: x86_64
name: Build ($BUILD_ARCH)
alias: build
macos_instance:
image: ghcr.io/cirruslabs/macos-runner:sequoia
build_script: swift build --arch $BUILD_ARCH --product tart
sign_script: codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/$BUILD_ARCH-apple-macosx/debug/tart
binary_artifacts:
path: .build/$BUILD_ARCH-apple-macosx/debug/tart
task:
only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin')
name: Release (Dry Run)
depends_on:
- lint
- build
macos_instance:
image: ghcr.io/cirruslabs/macos-runner:tahoe
image: ghcr.io/cirruslabs/macos-runner:sequoia
env:
MACOS_SIGN_P12: ENCRYPTED[!183482723ca1a95f9c4439f7a79c9d3b115472bb18c739ed1586e12d3914ccf94ade8169eeda7332fc204f8be9c27d9f!]
MACOS_SIGN_PASSWORD: ENCRYPTED[!417423346c567f12007f42d084bff1cfee30ee14f7e8258550157679a269c70d541c9f19224224ab0293b10f2c6d4c5e!]
KEYCHAIN_PASSWORD: password101
MACOS_NOTARY_PROFILE_NAME: notarytool
MACOS_NOTARY_ISSUER_ID: ENCRYPTED[!74076906e9fa36bca3c1da1637b0759b58bb009eb1a707446896eefad3767e8dba1d0f87e71106b98cde98ac4b037a2a!]
MACOS_NOTARY_KEY_ID: ENCRYPTED[!af9e5da1010a6b04e548ef494acc77a6e0ce176549de98f81c5b5cdd72856de09f77e51cf0849e3c4b7a2d2c22f25ca8!]
MACOS_NOTARY_KEY: ENCRYPTED[!c70c53f3e6c163931c7cdf9d90aff8934ef21d5dd1090158688e00b94e97c68257d9cf4ae1df873e6ae0d949866aee72!]
CERTIFICATE_PATH: "${CIRRUS_WORKING_DIR}/goreleaser.p12"
KEY_PATH: "${CIRRUS_WORKING_DIR}/goreleaser.p8"
KEYCHAIN_PATH: "${CIRRUS_WORKING_DIR}/goreleaser.keychain-db"
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
- brew install mitchellh/gon/gon
- brew install --cask goreleaser/tap/goreleaser-pro
info_script:
- security find-identity -v
- xcodebuild -version
- swift -version
goreleaser_script: |
# import certificate and key from secrets
echo -n "$MACOS_SIGN_P12" | base64 --decode -o $CERTIFICATE_PATH
echo -n "$MACOS_NOTARY_KEY" | base64 --decode -o $KEY_PATH
# create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# import certificate to keychain
security import $CERTIFICATE_PATH -P "$MACOS_SIGN_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
# create notary profile
xcrun notarytool store-credentials "notarytool" \
--key "$KEY_PATH" \
--key-id "$MACOS_NOTARY_KEY_ID" \
--issuer "$MACOS_NOTARY_ISSUER_ID" \
--keychain $KEYCHAIN_PATH
security find-identity -v
echo $KEYCHAIN_PATH
security default-keychain -s "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
goreleaser release --skip=publish --snapshot --clean
goreleaser_script: goreleaser release --skip=publish --snapshot --clean
always:
check_dist_script:
- find dist/
- du -hs dist/
dist_artifacts:
paths:
- dist/dmg/tart_all/Tart.dmg
- dist/homebrew/Casks/tart.rb
path: "dist/*"
task:
name: Release
only_if: $CIRRUS_TAG != ''
depends_on:
- lint
- test
- build
macos_instance:
image: ghcr.io/cirruslabs/macos-runner:sequoia
env:
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
SENTRY_ORG: cirrus-labs
SENTRY_PROJECT: persistent-workers
SENTRY_AUTH_TOKEN: ENCRYPTED[!9eaf2875d51b113e2f68598441ff8e6b2e53242e48fcb93633bd75a373fbe2e7caa900d837cc92f0b142b65579731644!]
setup_script:
- cd $HOME
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
- security create-keychain -p password101 build.keychain
- security default-keychain -s build.keychain
- security unlock-keychain -p password101 build.keychain
- security import certificate.p12 -k build.keychain -P password101 -T /usr/bin/codesign -T /usr/bin/pkgbuild
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
install_script:
- brew install go getsentry/tools/sentry-cli
- brew install mitchellh/gon/gon
- brew install --cask goreleaser/tap/goreleaser-pro
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
registry_config: ENCRYPTED[!cf1a0f25325aa75bad3ce6ebc890bc53eb0044c02efa70d8cefb83ba9766275a994b4831706c52630a0692b2fa9cfb9e!]
env:
DEPLOY_TOKEN: ENCRYPTED[!45ed45666558902ed1c2400add734ec063103bec31841847e8c8764802fca229bfa6d85c690e16ad159e047574b48793!]
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/
- mkdocs --verbose gh-deploy --force --remote-branch gh-pages
+9 -30
View File
@@ -1,4 +1,3 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json
version: 2
project_name: tart
@@ -18,38 +17,15 @@ builds:
goarch:
- arm64
- amd64
binary: tart
binary: tart.app/Contents/MacOS/tart
prebuilt:
path: '.build/{{- if eq .Arch "arm64" }}arm64{{- else }}x86_64{{ end }}-apple-macosx/release/tart'
universal_binaries:
- name_template: tart.app/Contents/MacOS/tart
replace: true
app_bundles:
- name: Tart
bundle: com.github.cirruslabs.tart
icon: Resources/AppIcon.icns
extra_files:
- src: Resources/embedded.provisionprofile
dst: Contents/embedded.provisionprofile
dmg:
- name: Tart
use: appbundle
replace: true
notarize:
macos_native:
- enabled: "true"
sign:
keychain: "{{.Env.KEYCHAIN_PATH}}"
identity: "Developer ID Application: Cirrus Labs, Inc."
options: [runtime]
entitlements: ./Resources/tart-prod.entitlements
notarize:
profile_name: "notarytool"
wait: true
hooks:
post: gon gon.hcl
archives:
- name_template: "{{ .ProjectName }}"
@@ -68,9 +44,8 @@ archives:
release:
prerelease: auto
homebrew_casks:
brews:
- name: tart
app: Tart.app
repository:
owner: cirruslabs
name: homebrew-cli
@@ -86,6 +61,10 @@ homebrew_casks:
description: Run macOS and Linux VMs on Apple Hardware
skip_upload: auto
dependencies:
- formula: "cirruslabs/cli/softnet"
- "cirruslabs/cli/softnet"
install: |
libexec.install Dir["*"]
bin.write_exec_script "#{libexec}/tart.app/Contents/MacOS/tart"
generate_completions_from_executable(libexec/"tart.app/Contents/MacOS/tart", "--generate-completion-script")
custom_block: |
depends_on :macos => :ventura
Binary file not shown.
+1 -1
View File
@@ -7,7 +7,7 @@
<key>CFBundleDisplayName</key>
<string>Tart</string>
<key>CFBundleIdentifier</key>
<string>com.github.cirruslabs.tart</string>
<string>org.cirruslabs.tart</string>
<key>CFBundleExecutable</key>
<string>tart</string>
<key>LSApplicationCategoryType</key>
+17 -1
View File
@@ -243,6 +243,13 @@ struct Run: AsyncParsableCommand {
@Flag(help: ArgumentHelp("Restrict network access to the host-only network"))
var netHost: Bool = false
@Option(help: ArgumentHelp("Use externally managed connected datagram socket file descriptor for VM networking (e.g. --net-fd=3)", discussion: """
This option allows integrating Tart with externally launched networking helpers.
The provided file descriptor must reference a connected datagram socket.
""", valueName: "fd", visibility: .hidden))
var netFd: Int32?
@Option(help: ArgumentHelp("Set the root disk options (e.g. --root-disk-opts=\"ro\" or --root-disk-opts=\"caching=cached,sync=none\")",
discussion: """
Options are comma-separated and are as follows:
@@ -295,14 +302,19 @@ struct Run: AsyncParsableCommand {
netSoftnet = true
}
if let netFd = netFd, netFd < 0 {
throw ValidationError("--net-fd must be greater than or equal to 0")
}
// Check that no more than one network option is specified
var netFlags = 0
if netBridged.count > 0 { netFlags += 1 }
if netSoftnet { netFlags += 1 }
if netHost { netFlags += 1 }
if netFd != nil { netFlags += 1 }
if netFlags > 1 {
throw ValidationError("--net-bridged, --net-softnet and --net-host are mutually exclusive")
throw ValidationError("--net-bridged, --net-softnet, --net-host and --net-fd are mutually exclusive")
}
if graphics && noGraphics {
@@ -620,6 +632,10 @@ struct Run: AsyncParsableCommand {
}
func userSpecifiedNetwork(vmDir: VMDirectory) throws -> Network? {
if let netFd = netFd {
return try NetworkFD(fd: netFd)
}
var softnetExtraArguments: [String] = []
if let netSoftnetAllow = netSoftnetAllow {
+74
View File
@@ -0,0 +1,74 @@
import Darwin
import Foundation
import Semaphore
import Virtualization
class NetworkFD: Network {
private let fd: Int32
init(fd: Int32) throws {
self.fd = fd
try Self.validateFD(fd)
try Self.validateSocketType(fd)
try Self.validateConnected(fd)
}
func attachments() -> [VZNetworkDeviceAttachment] {
[VZFileHandleNetworkDeviceAttachment(fileHandle: FileHandle(fileDescriptor: fd))]
}
func run(_ sema: AsyncSemaphore) throws {
// no-op, only used for Softnet
}
func stop() async throws {
// no-op, only used for Softnet
}
private static func validateFD(_ fd: Int32) throws {
if fcntl(fd, F_GETFD) == -1 {
throw RuntimeError.VMConfigurationError(
"invalid --net-fd \(fd): file descriptor is not open (\(errnoDescription(errno)))"
)
}
}
private static func validateSocketType(_ fd: Int32) throws {
var socketType: Int32 = 0
var optionLength = socklen_t(MemoryLayout<Int32>.size)
if getsockopt(fd, SOL_SOCKET, SO_TYPE, &socketType, &optionLength) == -1 {
throw RuntimeError.VMConfigurationError(
"invalid --net-fd \(fd): file descriptor must reference a socket (\(errnoDescription(errno)))"
)
}
if socketType != SOCK_DGRAM {
throw RuntimeError.VMConfigurationError(
"invalid --net-fd \(fd): expected SOCK_DGRAM socket, got \(socketType)"
)
}
}
private static func validateConnected(_ fd: Int32) throws {
var address = sockaddr_storage()
var addressLength = socklen_t(MemoryLayout<sockaddr_storage>.size)
let result = withUnsafeMutablePointer(to: &address) { pointer in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in
getpeername(fd, sockaddrPointer, &addressLength)
}
}
if result == -1 {
throw RuntimeError.VMConfigurationError(
"invalid --net-fd \(fd): socket must be connected (\(errnoDescription(errno)))"
)
}
}
private static func errnoDescription(_ code: CInt) -> String {
String(cString: strerror(code))
}
}
+83
View File
@@ -0,0 +1,83 @@
import Darwin
import Foundation
import XCTest
@testable import tart
final class NetworkFDTests: XCTestCase {
func testAcceptsConnectedDatagramSocket() throws {
let (fdLeft, fdRight) = try makeDatagramSocketPair()
defer {
_ = close(fdLeft)
_ = close(fdRight)
}
let network = try NetworkFD(fd: fdLeft)
XCTAssertEqual(network.attachments().count, 1)
}
func testRejectsClosedFileDescriptor() throws {
let (fdLeft, fdRight) = try makeDatagramSocketPair()
defer { _ = close(fdRight) }
_ = close(fdLeft)
XCTAssertThrowsError(try NetworkFD(fd: fdLeft)) { error in
self.assertVMConfigurationError(error, contains: "file descriptor is not open")
}
}
func testRejectsNonSocketFileDescriptor() throws {
let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
XCTAssertTrue(FileManager.default.createFile(atPath: fileURL.path, contents: Data()))
defer { try? FileManager.default.removeItem(at: fileURL) }
let fd = open(fileURL.path, O_RDONLY)
XCTAssertGreaterThanOrEqual(fd, 0)
defer { _ = close(fd) }
XCTAssertThrowsError(try NetworkFD(fd: fd)) { error in
self.assertVMConfigurationError(error, contains: "must reference a socket")
}
}
func testRejectsUnconnectedDatagramSocket() throws {
let fd = socket(AF_UNIX, SOCK_DGRAM, 0)
XCTAssertGreaterThanOrEqual(fd, 0)
defer { _ = close(fd) }
XCTAssertThrowsError(try NetworkFD(fd: fd)) { error in
self.assertVMConfigurationError(error, contains: "socket must be connected")
}
}
private func makeDatagramSocketPair() throws -> (Int32, Int32) {
var fds: [Int32] = [-1, -1]
let result = socketpair(AF_UNIX, SOCK_DGRAM, 0, &fds)
if result == -1 {
throw RuntimeError.VMConfigurationError("failed to create a datagram socketpair for tests")
}
return (fds[0], fds[1])
}
private func assertVMConfigurationError(
_ error: Error,
contains expectedSubstring: String,
file: StaticString = #filePath,
line: UInt = #line
) {
guard case RuntimeError.VMConfigurationError(let message) = error else {
XCTFail("Expected RuntimeError.VMConfigurationError, got \(error)", file: file, line: line)
return
}
XCTAssertTrue(
message.contains(expectedSubstring),
"Expected message to contain \"\(expectedSubstring)\", got \"\(message)\"",
file: file,
line: line
)
}
}
@@ -0,0 +1,42 @@
import XCTest
@testable import tart
final class RunNetworkValidationTests: XCTestCase {
func testNetFdRejectsNegativeValue() throws {
XCTAssertThrowsError(try Run.parseAsRoot(["unused", "--net-fd=-1"])) { error in
self.assertError(error, contains: "--net-fd must be greater than or equal to 0")
}
}
func testNetFdConflictsWithNetBridged() throws {
XCTAssertThrowsError(try Run.parseAsRoot(["unused", "--net-fd", "3", "--net-bridged=en0"])) { error in
self.assertError(error, contains: "--net-bridged, --net-softnet, --net-host and --net-fd are mutually exclusive")
}
}
func testNetFdConflictsWithNetSoftnet() throws {
XCTAssertThrowsError(try Run.parseAsRoot(["unused", "--net-fd", "3", "--net-softnet"])) { error in
self.assertError(error, contains: "--net-bridged, --net-softnet, --net-host and --net-fd are mutually exclusive")
}
}
func testNetFdConflictsWithNetHost() throws {
XCTAssertThrowsError(try Run.parseAsRoot(["unused", "--net-fd", "3", "--net-host"])) { error in
self.assertError(error, contains: "--net-bridged, --net-softnet, --net-host and --net-fd are mutually exclusive")
}
}
private func assertError(
_ error: Error,
contains expectedSubstring: String,
file: StaticString = #filePath,
line: UInt = #line
) {
XCTAssertTrue(
String(describing: error).contains(expectedSubstring),
"Expected error to contain \"\(expectedSubstring)\", got \"\(error)\"",
file: file,
line: line
)
}
}
+14
View File
@@ -77,6 +77,20 @@ Note: that accessing host is only possible with the default NAT network. If you
[Softnet](https://github.com/cirruslabs/softnet) (via `tart run --net-softnet <VM NAME>)`, then the network isolation
is stricter and it's not possible to access the host.
## Using externally managed networking (`--net-fd`)
For advanced integrations, `tart run` can consume a pre-opened connected datagram socket via `--net-fd`.
Unlike `--net-softnet`, Tart will not launch Softnet or configure Softnet permissions in this mode.
External launcher is responsible for:
* creating a connected datagram socketpair (for example, `socketpair(AF_UNIX, SOCK_DGRAM, ...)`)
* starting `softnet --vm-fd ...` (or another networking helper) with one end of that socketpair
* starting `tart run --net-fd <FD> <VM NAME>` with the other end inherited into Tart
If the file descriptor is invalid, not a datagram socket, or not connected, `tart run` fails fast.
## Changing the default NAT subnet
To change the default network to `192.168.77.1`:
+12
View File
@@ -0,0 +1,12 @@
source = [ "dist/tart_darwin_all/tart.app/Contents/MacOS/tart" ]
bundle_id = "com.github.cirruslabs.tart"
apple_id {
username = "hello@cirruslabs.org"
password = "@env:AC_PASSWORD"
}
sign {
application_identity = "Developer ID Application: Cirrus Labs, Inc."
entitlements_file = "Resources/tart-prod.entitlements"
}