diff --git a/.github/workflows/test-on-droplets-matrix.yml b/.github/workflows/test-on-droplets-matrix.yml index 61853bf7e..c9563ab82 100644 --- a/.github/workflows/test-on-droplets-matrix.yml +++ b/.github/workflows/test-on-droplets-matrix.yml @@ -37,6 +37,32 @@ jobs: run: | sudo apt-get install libsystemd-dev cmake libdbus-1-dev libglib2.0-dev + - name: Download and build required files for running tests. Copied from packaging/Makefile. + run: | + sudo mkdir --parents /opt/firecracker/ + sudo curl -fsSL -o "/opt/firecracker/vmlinux.bin" "https://ipfs.aleph.cloud/ipfs/bafybeiaj2lf6g573jiulzacvkyw4zzav7dwbo5qbeiohoduopwxs2c6vvy" + + rm -fr /tmp/firecracker-release + mkdir --parents /tmp/firecracker-release /opt/firecracker + curl -fsSL https://github.com/firecracker-microvm/firecracker/releases/download/v1.5.0/firecracker-v1.5.0-x86_64.tgz | tar -xz --no-same-owner --directory /tmp/firecracker-release + # Copy binaries: + cp /tmp/firecracker-release/release-v*/firecracker-v*[!.debug] /opt/firecracker/firecracker + cp /tmp/firecracker-release/release-v*/jailer-v*[!.debug] /opt/firecracker/jailer + chmod +x /opt/firecracker/firecracker + chmod +x /opt/firecracker/jailer + + find /opt + + - name: "Build custom runtime" + run: | + sudo apt update + sudo apt install -y debootstrap ndppd acl cloud-image-utils qemu-utils qemu-system-x86 + cd runtimes/aleph-debian-12-python && sudo ./create_disk_image.sh && cd ../.. + + - name: "Build example volume" + run: | + cd examples/volumes && bash build_squashfs.sh + # Unit tests create and delete network interfaces, and therefore require to run as root - name: Run unit tests run: | diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 000000000..67f9143b5 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,46 @@ +# Testing aleph-vm + +This procedure describes how to run tests on a local system. + +Tests also run on GitHub Actions via [the following workflow](./.github/workflows/test-on-droplets-matrix.yml). + +Since these tests create block devices and manipulate network interfaces, they need to run as root. +If you are not comfortable with this, run them in a virtual machine. + +## 1. Clone this repository + +```shell +git clone https://github.com/aleph-im/aleph-vm.git +``` + +## 2. Install [hatch](https://hatch.pypa.io/), the project manager + +Since installing tools globally is not recommended, we will install `hatch` + in a dedicated virtual environment. Alternatives include using [pipx](https://pipx.pypa.io) +or your distribution. + +```shell +python3 -m venv /opt/venv +source /opt/venv/bin/activate + +# Inside the venv +pip install hatch +``` + +## 3. Initialize hatch for running the tests + +It is required that the testing virtual environment relies on system packages +for `nftables` instead of the package obtained from `salsa.debian.org` as defined in +[pyproject.toml](./pyproject.toml). + +Create the testing virtual environment: +```shell +hatch env create testing +``` + + +## 4. Run tests + +```shell +hatch run testing:test +``` diff --git a/pyproject.toml b/pyproject.toml index f0d862b4b..cd803673e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "systemd-python==235", "systemd-python==235", "superfluid~=0.2.1", - "sqlalchemy[asyncio]", + "sqlalchemy[asyncio]>=2.0", "aiosqlite==0.19.0", "alembic==1.13.1", "aiohttp_cors~=0.7.0", @@ -83,6 +83,8 @@ config = "aleph-vm orchestrator config {args:--help}" check = "aleph-vm controller run {args:--help}" [tool.hatch.envs.testing] +type = "virtual" +system-packages = true dependencies = [ "pytest==8.0.1", "pytest-cov==4.1.0", @@ -135,6 +137,13 @@ all = [ pythonpath = [ "src" ] +testpaths = [ + "tests" +] +ignore = [ + "runtimes/aleph-debian-11-python/rootfs/", + "runtimes/aleph-debian-12-python/rootfs/", +] [tool.black] target-version = ["py39"] diff --git a/runtimes/aleph-debian-11-python/create_disk_image.sh b/runtimes/aleph-debian-11-python/create_disk_image.sh index 705b1fe84..bf05fbf48 100755 --- a/runtimes/aleph-debian-11-python/create_disk_image.sh +++ b/runtimes/aleph-debian-11-python/create_disk_image.sh @@ -38,8 +38,11 @@ pip3 install 'fastapi~=0.103.1' echo "Pip installing aleph-client" pip3 install 'aleph-sdk-python==0.7.0' -# Compile all Python bytecode -python3 -m compileall -f /usr/local/lib/python3.9 +# Compile Python code to bytecode for faster execution +# -o2 is needed to compile with optimization level 2 which is what we launch init1.py (`python -OO`) +# otherwise they are not used +python3 -m compileall -o 2 -f /usr/local/lib/python3.9 + echo "PubkeyAuthentication yes" >> /etc/ssh/sshd_config echo "PasswordAuthentication no" >> /etc/ssh/sshd_config diff --git a/runtimes/aleph-debian-12-python/create_disk_image.sh b/runtimes/aleph-debian-12-python/create_disk_image.sh index 18f2605a3..6a0c2265a 100755 --- a/runtimes/aleph-debian-12-python/create_disk_image.sh +++ b/runtimes/aleph-debian-12-python/create_disk_image.sh @@ -39,8 +39,10 @@ mkdir -p /opt/aleph/libs pip3 install --target /opt/aleph/libs 'aleph-sdk-python==0.9.0' 'fastapi~=0.109.2' # Compile Python code to bytecode for faster execution -python3 -m compileall -f /usr/local/lib/python3.11 -python3 -m compileall -f /opt/aleph/libs +# -o2 is needed to compile with optimization level 2 which is what we launch init1.py (`python -OO`) +# otherwise they are not used +python3 -m compileall -o 2 -f /usr/local/lib/python3.11 +python3 -m compileall -o 2 -f /opt/aleph/libs echo "PubkeyAuthentication yes" >> /etc/ssh/sshd_config echo "PasswordAuthentication no" >> /etc/ssh/sshd_config diff --git a/src/aleph/vm/conf.py b/src/aleph/vm/conf.py index b568cf6ae..29a5317f3 100644 --- a/src/aleph/vm/conf.py +++ b/src/aleph/vm/conf.py @@ -317,6 +317,7 @@ def update(self, **kwargs): raise ValueError(msg) def check(self): + """Check that the settings are valid. Call this method after self.setup().""" assert Path("/dev/kvm").exists(), "KVM not found on `/dev/kvm`." assert isfile(self.FIRECRACKER_PATH), f"File not found {self.FIRECRACKER_PATH}" assert isfile(self.JAILER_PATH), f"File not found {self.JAILER_PATH}" @@ -340,11 +341,17 @@ def check(self): assert self.FAKE_DATA_RUNTIME, "Local runtime .squashfs build not specified" assert self.FAKE_DATA_VOLUME, "Local data volume .squashfs not specified" - assert isdir(self.FAKE_DATA_PROGRAM), "Local fake program directory is missing" - assert isfile(self.FAKE_DATA_MESSAGE), "Local fake message is missing" - assert isdir(self.FAKE_DATA_DATA), "Local fake data directory is missing" - assert isfile(self.FAKE_DATA_RUNTIME), "Local runtime .squashfs build is missing" - assert isfile(self.FAKE_DATA_VOLUME), "Local data volume .squashfs is missing" + assert isdir( + self.FAKE_DATA_PROGRAM + ), f"Local fake program directory is missing, no directory '{self.FAKE_DATA_PROGRAM}'" + assert isfile(self.FAKE_DATA_MESSAGE), f"Local fake message '{self.FAKE_DATA_MESSAGE}' not found" + assert isdir(self.FAKE_DATA_DATA), f"Local fake data directory '{self.FAKE_DATA_DATA}' is missing" + assert isfile( + self.FAKE_DATA_RUNTIME + ), f"Local runtime '{self.FAKE_DATA_RUNTIME}' is missing, did you build it ?" + assert isfile( + self.FAKE_DATA_VOLUME + ), f"Local data volume '{self.FAKE_DATA_VOLUME}' is missing, did you build it ?" assert is_command_available("setfacl"), "Command `setfacl` not found, run `apt install acl`" if self.USE_NDP_PROXY: @@ -363,6 +370,7 @@ def check(self): ), "Command `qemu-system-x86_64` not found, run `apt install qemu-system-x86`" def setup(self): + """Setup the environment defined by the settings. Call this method after loading the settings.""" os.makedirs(self.MESSAGE_CACHE, exist_ok=True) os.makedirs(self.CODE_CACHE, exist_ok=True) os.makedirs(self.RUNTIME_CACHE, exist_ok=True) diff --git a/src/aleph/vm/guest_api/__main__.py b/src/aleph/vm/guest_api/__main__.py index b1df069fc..1d35997dd 100644 --- a/src/aleph/vm/guest_api/__main__.py +++ b/src/aleph/vm/guest_api/__main__.py @@ -1,6 +1,7 @@ import json import logging import re +from pathlib import Path from typing import Optional import aiohttp @@ -152,7 +153,7 @@ async def list_keys_from_cache(request: web.Request): def run_guest_api( - unix_socket_path, + unix_socket_path: Path, vm_hash: Optional[str] = None, sentry_dsn: Optional[str] = None, server_name: Optional[str] = None, @@ -195,8 +196,8 @@ def run_guest_api( app.router.add_route(method="POST", path="/api/v0/p2p/pubsub/pub", handler=repost) # web.run_app(app=app, port=9000) - web.run_app(app=app, path=unix_socket_path) + web.run_app(app=app, path=str(unix_socket_path)) if __name__ == "__main__": - run_guest_api("/tmp/guest-api", vm_hash="vm") + run_guest_api(Path("/tmp/guest-api"), vm_hash="vm") diff --git a/src/aleph/vm/orchestrator/__init__.py b/src/aleph/vm/orchestrator/__init__.py index b4c1907a4..d3c1f4225 100644 --- a/src/aleph/vm/orchestrator/__init__.py +++ b/src/aleph/vm/orchestrator/__init__.py @@ -1,30 +1,3 @@ from aleph.vm.version import __version__ -from . import ( - messages, - metrics, - pubsub, - reactor, - resources, - run, - status, - supervisor, - tasks, - views, - vm, -) - -__all__ = ( - "__version__", - "messages", - "metrics", - "pubsub", - "reactor", - "resources", - "run", - "status", - "supervisor", - "tasks", - "views", - "vm", -) +__all__ = ("__version__",) diff --git a/tests/supervisor/test_execution.py b/tests/supervisor/test_execution.py new file mode 100644 index 000000000..551e866e9 --- /dev/null +++ b/tests/supervisor/test_execution.py @@ -0,0 +1,95 @@ +import asyncio +import logging + +import pytest +from aleph_message.models import ItemHash + +from aleph.vm.conf import settings +from aleph.vm.controllers.firecracker import AlephFirecrackerProgram +from aleph.vm.models import VmExecution +from aleph.vm.orchestrator import metrics +from aleph.vm.storage import get_message + + +@pytest.mark.asyncio +async def test_create_execution(): + """ + Create a new VM execution and check that it starts properly. + """ + + settings.FAKE_DATA_PROGRAM = settings.BENCHMARK_FAKE_DATA_PROGRAM + settings.ALLOW_VM_NETWORKING = False + settings.USE_JAILER = False + + logging.basicConfig(level=logging.DEBUG) + settings.PRINT_SYSTEM_LOGS = True + + # Ensure that the settings are correct and required files present. + settings.setup() + settings.check() + + # The database is required for the metrics and is currently not optional. + engine = metrics.setup_engine() + await metrics.create_tables(engine) + + vm_hash = ItemHash("cafecafecafecafecafecafecafecafecafecafecafecafecafecafecafecafe") + message = await get_message(ref=vm_hash) + + execution = VmExecution( + vm_hash=vm_hash, + message=message.content, + original=message.content, + snapshot_manager=None, + systemd_manager=None, + persistent=False, + ) + + # Downloading the resources required may take some time, limit it to 10 seconds + await asyncio.wait_for(execution.prepare(), timeout=30) + + vm = execution.create(vm_id=3, tap_interface=None) + + # Test that the VM is created correctly. It is not started yet. + assert isinstance(vm, AlephFirecrackerProgram) + assert vm.vm_id == 3 + + await execution.start() + await execution.stop() + + +@pytest.mark.asyncio +async def test_create_execution_online(): + """ + Create a new VM execution without building it locally and check that it starts properly. + """ + + # Ensure that the settings are correct and required files present. + settings.setup() + settings.check() + + # The database is required for the metrics and is currently not optional. + engine = metrics.setup_engine() + await metrics.create_tables(engine) + + vm_hash = ItemHash("3fc0aa9569da840c43e7bd2033c3c580abb46b007527d6d20f2d4e98e867f7af") + message = await get_message(ref=vm_hash) + + execution = VmExecution( + vm_hash=vm_hash, + message=message.content, + original=message.content, + snapshot_manager=None, + systemd_manager=None, + persistent=False, + ) + + # Downloading the resources required may take some time, limit it to 10 seconds + await asyncio.wait_for(execution.prepare(), timeout=30) + + vm = execution.create(vm_id=3, tap_interface=None) + # Test that the VM is created correctly. It is not started yet. + assert isinstance(vm, AlephFirecrackerProgram) + assert vm.vm_id == 3 + + await execution.start() + await execution.stop()