diff --git a/.cirrus.yml b/.cirrus.yml index 6162c83..98c046f 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -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: diff --git a/Sources/tart/VM.swift b/Sources/tart/VM.swift index 3812d6c..9f0c998 100644 --- a/Sources/tart/VM.swift +++ b/Sources/tart/VM.swift @@ -138,6 +138,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 diff --git a/integration-tests/conftest.py b/integration-tests/conftest.py new file mode 100644 index 0000000..f85518b --- /dev/null +++ b/integration-tests/conftest.py @@ -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 diff --git a/integration-tests/docker_registry.py b/integration-tests/docker_registry.py new file mode 100644 index 0000000..b58a004 --- /dev/null +++ b/integration-tests/docker_registry.py @@ -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" diff --git a/integration-tests/requirements.txt b/integration-tests/requirements.txt new file mode 100644 index 0000000..7e6a7dc --- /dev/null +++ b/integration-tests/requirements.txt @@ -0,0 +1,5 @@ +pytest +testcontainers +requests +bitmath +pytest-dependency diff --git a/integration-tests/tart.py b/integration-tests/tart.py new file mode 100644 index 0000000..b26a263 --- /dev/null +++ b/integration-tests/tart.py @@ -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") diff --git a/integration-tests/test_clone.py b/integration-tests/test_clone.py new file mode 100644 index 0000000..2519bc7 --- /dev/null +++ b/integration-tests/test_clone.py @@ -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" diff --git a/integration-tests/test_create.py b/integration-tests/test_create.py new file mode 100644 index 0000000..311e80b --- /dev/null +++ b/integration-tests/test_create.py @@ -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" diff --git a/integration-tests/test_delete.py b/integration-tests/test_delete.py new file mode 100644 index 0000000..760d292 --- /dev/null +++ b/integration-tests/test_delete.py @@ -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 == "" diff --git a/integration-tests/test_oci.py b/integration-tests/test_oci.py new file mode 100644 index 0000000..4489b35 --- /dev/null +++ b/integration-tests/test_oci.py @@ -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]) diff --git a/integration-tests/test_rename.py b/integration-tests/test_rename.py new file mode 100644 index 0000000..6eb709f --- /dev/null +++ b/integration-tests/test_rename.py @@ -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"