Compare commits

..
8 Commits
Author SHA1 Message Date
Nikolay Edigaryev 456ebc1c7b Rosetta support (#324) 2022-11-21 12:56:27 -05:00
Nikolay Edigaryev 14cbc727ad tart run: check if the disks are locked (#333) 2022-11-21 17:42:53 +04:00
Fedor Korotkov 03e6d9345d Document list of users (#332)
Fixes #208
2022-11-21 12:34:24 +04:00
Nikolay Edigaryev c09cbefdd0 VZVirtualMachineDelegate implementation: show the error details (#330) 2022-11-18 18:51:26 +04:00
Fedor Korotkov 3896728eb9 Added Code Owners (#328)
* Added Code Owners

* Rename gi to CODEOWNERS
2022-11-17 22:57:12 -05:00
sheldonneuberger-sc de993fbf6d use 64bit int for memory (#327) 2022-11-16 14:52:28 -05:00
Nikolay Edigaryev f08ddf3855 Fetch IPSWs via file (#322) 2022-11-15 10:01:52 +04:00
Nikolay Edigaryev 8524d93741 Integration tests (#313)
* Integration tests

* Set "HOMEBREW_NO_AUTO_UPDATE=1" for virtualenv installation step

* Use CIRRUS_WORKING_DIR as temporary directory if present
2022-11-14 13:24:51 -05:00
20 changed files with 339 additions and 39 deletions
+23 -3
View File
@@ -1,13 +1,30 @@
task:
name: Test on Ventura
alias: test
persistent_worker:
labels:
name: Mac-Mini-M1
build_script: swift test
test_script: swift test
name: scaleway-m1
test_script:
- swift test
integration_test_script:
# Build Tart
- swift build
- codesign --sign - --entitlements Resources/tart.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
- source venv/bin/activate
- pip install -r requirements.txt
- pytest --verbose --junit-xml=pytest-junit.xml
pytest_junit_result_artifacts:
path: "integration-tests/pytest-junit.xml"
format: junit
task:
name: Build
alias: build
only_if: $CIRRUS_TAG == ''
macos_instance:
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
@@ -19,6 +36,9 @@ task:
task:
name: Release
only_if: $CIRRUS_TAG != ''
depends_on:
- test
- build
macos_instance:
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
env:
+1
View File
@@ -0,0 +1 @@
* @edigaryev @fkorotkov
+16
View File
@@ -22,6 +22,22 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
</a>
</p>
Many more 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://suran.com/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Suran.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"/>
</a>
</p>
**Note:** If your company or project is using Tart please consider [adding yourself to the list above](/Resources/Users/HowToAddYourself.md).
## Usage
Try running a Tart VM on your Apple Silicon device running macOS Monterey or later (will download a 25 GB image):
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4d1f58f6460217434ae133c6d6e444320eb705c5dcf9efebbb215b117ae452c1
size 589
+4
View File
@@ -0,0 +1,4 @@
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.
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:630c4975b9eead9e4f62e2a5375509dc0ee6cab2940ec3ffc2ae16b08d325964
size 6765
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5e48fbe90f6cc84e100fc286a5349c681cacb22358811181046d35ea298309de
size 16379
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1c7f7f9bf0fc0c9807f3a415fe6bf773c8a416b351c5aa08de46149a13de7387
size 5297
+83 -6
View File
@@ -48,6 +48,20 @@ struct Run: AsyncParsableCommand {
""", valueName: "path[:ro]"))
var disk: [String] = []
@Option(name: [.customLong("rosetta")], help: ArgumentHelp(
"Attaches a Rosetta share to the guest Linux VM with a specific tag (e.g. --rosetta=\"rosetta\")",
discussion: """
Requires host to be macOS 13.0 (Ventura) with Rosetta installed. The latter can be done
by running "softwareupdate --install-rosetta" (without quotes) in the Terminal.app.
Note that you also have to configure Rosetta in the guest Linux VM by following the
steps from "Mount the Shared Directory and Register Rosetta" section here:
https://developer.apple.com/documentation/virtualization/running_intel_binaries_in_linux_vms_with_rosetta#3978496
""",
valueName: "tag"
))
var rosettaTag: String?
@Option(help: ArgumentHelp("""
Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\")
""", discussion: """
@@ -90,11 +104,24 @@ struct Run: AsyncParsableCommand {
@MainActor
func run() async throws {
let vmDir = try VMStorageLocal().open(name)
let additionalDiskAttachments = try additionalDiskAttachments()
// Error out if the disk is locked by the host (e.g. it was mounted in Finder),
// see https://github.com/cirruslabs/tart/issues/323 for more details.
for additionalDiskAttachment in additionalDiskAttachments {
if try !FileLock(lockURL: additionalDiskAttachment.url).trylock() {
print("disk \(additionalDiskAttachment.url.path) seems to be already in use, "
+ "unmount it first in Finder")
Foundation.exit(1)
}
}
vm = try VM(
vmDir: vmDir,
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
additionalDiskAttachments: additionalDiskAttachments(),
directoryShares: directoryShares()
additionalDiskAttachments: additionalDiskAttachments,
directorySharingDevices: directoryShares() + rosettaDirectoryShare()
)
let vncImpl: VNC? = try {
@@ -229,8 +256,22 @@ struct Run: AsyncParsableCommand {
return result
}
func directoryShares() throws -> [DirectoryShare] {
var result: [DirectoryShare] = []
func directoryShares() throws -> [VZDirectorySharingDeviceConfiguration] {
if dir.isEmpty {
return []
}
guard #available(macOS 13, *) else {
throw UnsupportedOSError("directory sharing", "is")
}
struct DirectoryShare {
let name: String
let path: URL
let readOnly: Bool
}
var directoryShares: [DirectoryShare] = []
for rawDir in dir {
let splits = rawDir.split(maxSplits: 2) { $0 == ":" }
@@ -251,10 +292,46 @@ struct Run: AsyncParsableCommand {
let (name, path) = (String(splits[0]), String(splits[1]))
result.append(DirectoryShare(name: name, path: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath), readOnly: readOnly))
directoryShares.append(DirectoryShare(
name: name,
path: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath),
readOnly: readOnly)
)
}
return result
var directories: [String : VZSharedDirectory] = Dictionary()
directoryShares.forEach { directories[$0.name] = VZSharedDirectory(url: $0.path, readOnly: $0.readOnly) }
let automountTag = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: automountTag)
sharingDevice.share = VZMultipleDirectoryShare(directories: directories)
return [sharingDevice]
}
private func rosettaDirectoryShare() throws -> [VZDirectorySharingDeviceConfiguration] {
guard let rosettaTag = rosettaTag else {
return []
}
guard #available(macOS 13, *) else {
throw UnsupportedOSError("Rosetta directory share", "is")
}
switch VZLinuxRosettaDirectoryShare.availability {
case .notInstalled:
throw UnsupportedOSError("Rosetta directory share", "is", "that have Rosetta installed")
case .notSupported:
throw UnsupportedOSError("Rosetta directory share", "is", "running Apple silicon")
default:
break
}
try VZVirtioFileSystemDeviceConfiguration.validateTag(rosettaTag)
let device = VZVirtioFileSystemDeviceConfiguration(tag: rosettaTag)
device.share = try VZLinuxRosettaDirectoryShare()
return [device]
}
private func runUI() {
+2 -2
View File
@@ -11,7 +11,7 @@ struct Set: AsyncParsableCommand {
var cpu: UInt16?
@Option(help: "VM memory size in megabytes")
var memory: UInt16?
var memory: UInt64?
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
var display: VMDisplayConfig?
@@ -29,7 +29,7 @@ struct Set: AsyncParsableCommand {
}
if let memory = memory {
try vmConfig.setMemory(memorySize: UInt64(memory) * 1024 * 1024)
try vmConfig.setMemory(memorySize: memory * 1024 * 1024)
}
if let display = display {
+19 -28
View File
@@ -14,8 +14,8 @@ struct DownloadFailed: Error {
struct UnsupportedOSError: Error, CustomStringConvertible {
let description: String
init(_ what: String, _ plural: String) {
description = "error: \(what) \(plural) only supported on hosts running macOS 13.0 (Ventura) or newer"
init(_ what: String, _ plural: String, _ requires: String = "running macOS 13.0 (Ventura) or newer") {
description = "error: \(what) \(plural) only supported on hosts \(requires)"
}
}
@@ -40,7 +40,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
init(vmDir: VMDirectory,
network: Network = NetworkShared(),
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
directoryShares: [DirectoryShare] = []
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = []
) throws {
name = vmDir.name
config = try VMConfig.init(fromURL: vmDir.configURL)
@@ -54,7 +54,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
nvramURL: vmDir.nvramURL, vmConfig: config,
network: network, additionalDiskAttachments: additionalDiskAttachments,
directoryShares: directoryShares)
directorySharingDevices: directorySharingDevices
)
virtualMachine = VZVirtualMachine(configuration: configuration)
super.init()
@@ -63,7 +64,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
static func retrieveIPSW(remoteURL: URL) async throws -> URL {
// Check if we already have this IPSW in cache
let (channel, response) = try await Fetcher.fetch(URLRequest(url: remoteURL))
let (channel, response) = try await Fetcher.fetch(URLRequest(url: remoteURL), viaFile: true)
if let hash = response.value(forHTTPHeaderField: "x-amz-meta-digest-sha256") {
let ipswLocation = try IPSWCache().locationFor(fileName: "sha256:\(hash).ipsw")
@@ -130,7 +131,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
ipswURL: URL,
diskSizeGB: UInt16,
network: Network = NetworkShared(),
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = []
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = []
) async throws {
var ipswURL = ipswURL
@@ -138,6 +140,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
ipswURL = try await VM.retrieveIPSW(remoteURL: ipswURL)
}
// We create a temporary TART_HOME directory in tests, which has its "cache" folder symlinked
// to the users Tart cache directory (~/.tart/cache). However, the Virtualization.Framework
// cannot deal with paths that contain symlinks, so expand them here first.
ipswURL.resolveSymlinksInPath()
// Load the restore image and try to get the requirements
// that match both the image and our platform
let image = try await withCheckedThrowingContinuation { continuation in
@@ -172,7 +179,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
vmConfig: config, network: network,
additionalDiskAttachments: additionalDiskAttachments,
directoryShares: [])
directorySharingDevices: directorySharingDevices
)
virtualMachine = VZVirtualMachine(configuration: configuration)
super.init()
@@ -248,7 +256,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
vmConfig: VMConfig,
network: Network = NetworkShared(),
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
directoryShares: [DirectoryShare]
directorySharingDevices: [VZDirectorySharingDeviceConfiguration]
) throws -> VZVirtualMachineConfiguration {
let configuration = VZVirtualMachineConfiguration()
@@ -292,19 +300,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Entropy
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
// Directory share
if #available(macOS 13, *) {
var directories: [String : VZSharedDirectory] = Dictionary()
directoryShares.forEach { directories[$0.name] = VZSharedDirectory(url: $0.path, readOnly: $0.readOnly) }
let automountTag = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: automountTag)
sharingDevice.share = VZMultipleDirectoryShare(directories: directories)
configuration.directorySharingDevices = [sharingDevice]
} else if !directoryShares.isEmpty {
throw UnsupportedOSError("directory sharing", "is")
}
// Directory sharing devices
configuration.directorySharingDevices = directorySharingDevices
try configuration.validate()
@@ -317,7 +314,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
}
func virtualMachine(_ virtualMachine: VZVirtualMachine, didStopWithError error: Error) {
print("guest has stopped the virtual machine due to error")
print("guest has stopped the virtual machine due to error: \(error)")
sema.signal()
}
@@ -326,9 +323,3 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
sema.signal()
}
}
struct DirectoryShare {
let name: String
let path: URL
let readOnly: Bool
}
+16
View File
@@ -0,0 +1,16 @@
import pytest
from tart import Tart
from docker_registry import DockerRegistry
@pytest.fixture(scope="class")
def tart():
with Tart() as tart:
yield tart
@pytest.fixture(scope="class")
def docker_registry():
with DockerRegistry() as docker_registry:
yield docker_registry
+20
View File
@@ -0,0 +1,20 @@
import requests
from testcontainers.core.waiting_utils import wait_container_is_ready
from testcontainers.general import DockerContainer
class DockerRegistry(DockerContainer):
_default_exposed_port = 5000
def __init__(self):
super().__init__("registry:2")
self.with_exposed_ports(self._default_exposed_port)
@wait_container_is_ready(requests.exceptions.ConnectionError)
def remote_name(self, for_vm: str):
exposed_port = self.get_exposed_port(self._default_exposed_port)
requests.get(f"http://127.0.0.1:{exposed_port}/v2/")
return f"127.0.0.1:{exposed_port}/tart/{for_vm}:latest"
+5
View File
@@ -0,0 +1,5 @@
pytest
testcontainers
requests
bitmath
pytest-dependency
+33
View File
@@ -0,0 +1,33 @@
import tempfile
import os
import subprocess
class Tart:
def __init__(self):
self.tmp_dir = tempfile.TemporaryDirectory(dir=os.environ.get("CIRRUS_WORKING_DIR"))
# Link to the users IPSW cache to make things faster
src = os.path.join(os.path.expanduser("~"), ".tart", "cache", "IPSWs")
dst = os.path.join(self.tmp_dir.name, "cache", "IPSWs")
os.makedirs(os.path.join(self.tmp_dir.name, "cache"))
os.symlink(src, dst)
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.tmp_dir.cleanup()
def home(self) -> str:
return self.tmp_dir.name
def run(self, args):
env = os.environ.copy()
env.update({"TART_HOME": self.tmp_dir.name})
completed_process = subprocess.run(["tart"] + args, env=env, capture_output=True)
completed_process.check_returncode()
return completed_process.stdout.decode("utf-8"), completed_process.stderr.decode("utf-8")
+10
View File
@@ -0,0 +1,10 @@
def test_clone(tart):
# Create a Linux VM (because we can create it really fast)
tart.run(["create", "--linux", "debian"])
# Clone the VM
tart.run(["clone", "debian", "ubuntu"])
# Ensure that we have now 2 VMs
stdout, _, = tart.run(["list", "--quiet"])
assert stdout == "debian\nubuntu\n"
+16
View File
@@ -0,0 +1,16 @@
def test_create_macos(tart):
# Create a macOS VM
tart.run(["create", "--from-ipsw", "latest", "macos-vm"])
# Ensure that the VM was created
stdout, _ = tart.run(["list", "--quiet"])
assert stdout == "macos-vm\n"
def test_create_linux(tart):
# Create a Linux VM
tart.run(["create", "--linux", "linux-vm"])
# Ensure that the VM was created
stdout, _ = tart.run(["list", "--quiet"])
assert stdout == "linux-vm\n"
+14
View File
@@ -0,0 +1,14 @@
def test_delete(tart):
# Create a Linux VM (because we can create it really fast)
tart.run(["create", "--linux", "debian"])
# Ensure that the VM exists
stdout, _, = tart.run(["list", "--quiet"])
assert stdout == "debian\n"
# Delete the VM
tart.run(["delete", "debian"])
# Ensure that the VM was removed
stdout, _, = tart.run(["list", "--quiet"])
assert stdout == ""
+55
View File
@@ -0,0 +1,55 @@
import os
import tempfile
import timeit
import uuid
import bitmath
import pytest
amount_to_transfer = bitmath.GB(1)
minimal_speed_per_second = bitmath.Mb(100)
class TestOCI:
@pytest.mark.dependency()
def test_push_speed(self, tart, vm_with_random_disk, docker_registry):
start = timeit.default_timer()
tart.run(["push", "--insecure", vm_with_random_disk, docker_registry.remote_name(vm_with_random_disk)])
stop = timeit.default_timer()
actual_speed_per_second = self._calculate_speed_per_second(amount_to_transfer, stop - start)
assert actual_speed_per_second > minimal_speed_per_second
@pytest.mark.dependency(depends=["TestOCI::test_push_speed"])
def test_pull_speed(self, tart, vm_with_random_disk, docker_registry):
start = timeit.default_timer()
tart.run(["pull", "--insecure", docker_registry.remote_name(vm_with_random_disk)])
stop = timeit.default_timer()
actual_speed_per_second = self._calculate_speed_per_second(amount_to_transfer, stop - start)
assert actual_speed_per_second > minimal_speed_per_second
@staticmethod
def _calculate_speed_per_second(amount_transferred, time_taken):
return (amount_transferred / time_taken).best_prefix(bitmath.SI)
@pytest.fixture(scope="class")
def vm_with_random_disk(tart):
vm_name = str(uuid.uuid4())
# Create a VM (Linux for speed's sake)
tart.run(["create", "--linux", vm_name])
# Populate VM's disk with "amount_to_transfer" of random bytes
# to effectively disable Tart's OCI blob compression
disk_path = os.path.join(tart.home(), "vms", vm_name, "disk.img")
with tempfile.NamedTemporaryFile(delete=False) as tf:
tf.write(os.urandom(amount_to_transfer.bytes))
tf.close()
os.rename(tf.name, disk_path)
yield vm_name
tart.run(["delete", vm_name])
+10
View File
@@ -0,0 +1,10 @@
def test_rename(tart):
# Create a Linux VM (because we can create it really fast)
tart.run(["create", "--linux", "debian"])
# Rename that VM
tart.run(["rename", "debian", "ubuntu"])
# Ensure that the VM is now named "ubuntu"
stdout, _, = tart.run(["list", "--quiet"])
assert stdout == "ubuntu\n"