Begin Firecracker microVM support

This commit is contained in:
Joakim Hellsén 2026-04-25 06:34:37 +02:00
commit ed8ad1bee9
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
11 changed files with 290 additions and 7 deletions

View file

@ -13,7 +13,7 @@ Auto-Clarity: drop caveman for security warnings, irreversible actions, user con
# Context: Tussilago # Context: Tussilago
A platform to run and host applications, with a focus on Python applications. A platform to run and host applications, with a focus on Python applications.
- **Tech Stack**: Python 3.14+, Django 6, Celery, SQLite (platform), PostgreSQL/Redis (tenants). - **Tech Stack**: Python 3.14+, Django 6, Celery, SQLite (platform), PostgreSQL/Redis (tenants).
- **Infrastructure**: Podman, gVisor (sandboxing), Caddy (reverse proxy/load balancer). - **Infrastructure**: Firecracker microVMs (sandboxing), Podman (image/build workflow), Caddy (reverse proxy/load balancer).
- **Tooling**: `uv` (package manager/runner), `pytest`, `ruff`. - **Tooling**: `uv` (package manager/runner), `pytest`, `ruff`.
## CRITICAL: Security & Architecture constraints ## CRITICAL: Security & Architecture constraints
@ -22,6 +22,7 @@ A platform to run and host applications, with a focus on Python applications.
- **Subprocess**: NEVER use `os.system`. MUST use `subprocess.run(..., check=True, capture_output=True, text=True)`. Handle `subprocess.CalledProcessError` explicitly. - **Subprocess**: NEVER use `os.system`. MUST use `subprocess.run(..., check=True, capture_output=True, text=True)`. Handle `subprocess.CalledProcessError` explicitly.
- **Database IDs**: NEVER expose internal Linux UIDs (integers) to the frontend/API. MUST use `UUIDv4` for external references. - **Database IDs**: NEVER expose internal Linux UIDs (integers) to the frontend/API. MUST use `UUIDv4` for external references.
- **Caddy**: MUST interact with Caddy strictly via its JSON REST API. - **Caddy**: MUST interact with Caddy strictly via its JSON REST API.
- **Firecracker Isolation**: Tenant workloads MUST run inside Firecracker microVM boundaries. Do not assume host-level process trust; enforce per-VM network/storage isolation and explicit lifecycle control.
- **PostgreSQL Isolation**: For shared-tier DBs, use logical isolation. Create roles with `NOSUPERUSER`, `CONNECTION LIMIT`, and configured `statement_timeout`. - **PostgreSQL Isolation**: For shared-tier DBs, use logical isolation. Create roles with `NOSUPERUSER`, `CONNECTION LIMIT`, and configured `statement_timeout`.
- **Connection Pooling**: Assume PgBouncer is active. In Django settings, `DISABLE_SERVER_SIDE_CURSORS = True` MUST be set. - **Connection Pooling**: Assume PgBouncer is active. In Django settings, `DISABLE_SERVER_SIDE_CURSORS = True` MUST be set.
@ -67,7 +68,7 @@ A platform to run and host applications, with a focus on Python applications.
- **Celery Tasks**: - **Celery Tasks**:
- **Idempotency**: All tasks MUST be idempotent. If a task fails halfway and retries, it MUST NOT corrupt the system or create duplicate containers. - **Idempotency**: All tasks MUST be idempotent. If a task fails halfway and retries, it MUST NOT corrupt the system or create duplicate containers.
- **Serialization**: NEVER pass Django ORM instances as task arguments. Pass `UUID`s or primitive types and refetch the object inside the task. - **Serialization**: NEVER pass Django ORM instances as task arguments. Pass `UUID`s or primitive types and refetch the object inside the task.
- **Retries**: Always implement bounded exponential backoff for external interactions (e.g., interacting with the Caddy API or waiting for gVisor). - **Retries**: Always implement bounded exponential backoff for external interactions (e.g., interacting with the Caddy API or waiting for Firecracker microVM readiness).
## Testing Standards ## Testing Standards
- **Mocking**: NEVER allow test suites to execute real `podman` subprocess calls or make real HTTP requests to Caddy. MUST use `unittest.mock.patch` or `responses`/`httpx-mock` for external boundaries. - **Mocking**: NEVER allow test suites to execute real `podman` subprocess calls or make real HTTP requests to Caddy. MUST use `unittest.mock.patch` or `responses`/`httpx-mock` for external boundaries.

4
.gitignore vendored
View file

@ -161,3 +161,7 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear # and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
# Ignore files related to Firecracker microVM, except for get_rootfs.bash which is used in image preparation.
Firecracker/*
!Firecracker/get_rootfs.bash

40
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,40 @@
# Contributing
## TL;DR
### 1 Install system packages
```bash
sudo pacman -Syu --needed \
base-devel \
git \
python \
uv \
sqlite \
curl \
wget \
openssh \
squashfs-tools \
e2fsprogs \
caddy \
podman
```
Notes:
- `uv` is package/dependency runner used by repo.
- `squashfs-tools`, `openssh`, and `e2fsprogs` are needed for `get_rootfs.bash` image prep flow.
### 2 Bootstrap project
```bash
uv sync
```
### 3 Day-to-day commands
```bash
uv run python manage.py check
uv run pytest -n 5 -q
uv run ruff check . --fix
uv run ruff format .
```

92
Firecracker/get_rootfs.bash Executable file
View file

@ -0,0 +1,92 @@
#!/bin/bash
# This script downloads the latest Linux kernel and rootfs images from Firecracker CI, patches the rootfs with an SSH key, and creates an ext4 filesystem image.
required_commands=(
uname
curl
basename
grep
sort
tail
wget
unsquashfs
ssh-keygen
sudo
chown
truncate
mkfs.ext4
e2fsck
)
missing_commands=()
for cmd in "${required_commands[@]}"; do
if ! command -v "$cmd" >/dev/null 2>&1; then
missing_commands+=("$cmd")
fi
done
if [ ${#missing_commands[@]} -ne 0 ]; then
echo "ERROR: Missing required program(s): ${missing_commands[*]}" >&2
echo "Install missing dependencies and rerun." >&2
exit 1
fi
ARCH="$(uname -m)"
release_url="https://github.com/firecracker-microvm/firecracker/releases"
latest_version=$(basename "$(curl -fsSLI -o /dev/null -w "%{url_effective}" "${release_url}/latest")")
CI_VERSION=${latest_version%.*}
latest_kernel_key=$(curl "http://spec.ccfc.min.s3.amazonaws.com/?prefix=firecracker-ci/$CI_VERSION/$ARCH/vmlinux-&list-type=2" \
| grep -oP "(?<=<Key>)(firecracker-ci/$CI_VERSION/$ARCH/vmlinux-[0-9]+\.[0-9]+\.[0-9]{1,3})(?=</Key>)" \
| sort -V | tail -1)
# Download a linux kernel binary
wget "https://s3.amazonaws.com/spec.ccfc.min/${latest_kernel_key}"
latest_ubuntu_key=$(curl "http://spec.ccfc.min.s3.amazonaws.com/?prefix=firecracker-ci/$CI_VERSION/$ARCH/ubuntu-&list-type=2" \
| grep -oP "(?<=<Key>)(firecracker-ci/$CI_VERSION/$ARCH/ubuntu-[0-9]+\.[0-9]+\.squashfs)(?=</Key>)" \
| sort -V | tail -1)
ubuntu_version=$(basename "$latest_ubuntu_key" .squashfs | grep -oE '[0-9]+\.[0-9]+')
# Download a rootfs from Firecracker CI
wget -O "ubuntu-${ubuntu_version}.squashfs.upstream" "https://s3.amazonaws.com/spec.ccfc.min/$latest_ubuntu_key"
# The rootfs in our CI doesn't contain SSH keys to connect to the VM
# For the purpose of this demo, let's create one and patch it in the rootfs
unsquashfs "ubuntu-${ubuntu_version}.squashfs.upstream"
ssh-keygen -f id_rsa -N ""
cp -v id_rsa.pub squashfs-root/root/.ssh/authorized_keys
mv -v id_rsa "./ubuntu-${ubuntu_version}.id_rsa"
# create ext4 filesystem image
sudo chown -R root:root squashfs-root
truncate -s 1G "ubuntu-${ubuntu_version}.ext4"
sudo mkfs.ext4 -d squashfs-root -F "ubuntu-${ubuntu_version}.ext4"
# Verify everything was correctly set up and print versions
echo
echo "The following files were downloaded and set up:"
kernel_files=(./vmlinux-*)
if [ -e "${kernel_files[0]}" ]; then
last_kernel_index=$((${#kernel_files[@]} - 1))
KERNEL="${kernel_files[$last_kernel_index]}"
echo "Kernel: $KERNEL"
else
echo "ERROR: Kernel image does not exist"
fi
rootfs_files=(./*.ext4)
if [ -e "${rootfs_files[0]}" ]; then
last_rootfs_index=$((${#rootfs_files[@]} - 1))
ROOTFS="${rootfs_files[$last_rootfs_index]}"
e2fsck -fn "$ROOTFS" &>/dev/null && echo "Rootfs: $ROOTFS" || echo "ERROR: $ROOTFS is not a valid ext4 fs"
else
echo "ERROR: Rootfs image does not exist"
fi
key_files=(./*.id_rsa)
if [ -e "${key_files[0]}" ]; then
last_key_index=$((${#key_files[@]} - 1))
KEY_NAME="${key_files[$last_key_index]}"
echo "SSH Key: $KEY_NAME"
else
echo "ERROR: SSH key does not exist"
fi

View file

@ -6,6 +6,10 @@ Email: tlovinator@gmail.com
Discord: TheLovinator#9276 Discord: TheLovinator#9276
# Contributing
See `CONTRIBUTING.md` for developer setup and workflow.
# License # License
The AGPL license applies to the infrastructure platform (the SaaS backend, the deployment scripts, the web UI). It does not infect the user's code. If a tenant hosts a proprietary, closed-source application on this platform, their code remains completely theirs. The AGPL license applies to the infrastructure platform (the SaaS backend, the deployment scripts, the web UI). It does not infect the user's code. If a tenant hosts a proprietary, closed-source application on this platform, their code remains completely theirs.

14
config/apps.py Normal file
View file

@ -0,0 +1,14 @@
from django.apps import AppConfig
class TussilagoConfig(AppConfig):
"""Application config used to register startup hooks for config package."""
# TODO(TheLovinator): Consider creating an app with a more descriptive name for its purpose, e.g. "core" or "common". # noqa: TD003
name = "config"
verbose_name = "Tussilago Config"
def ready(self) -> None:
"""Register Django system checks after app loading."""
import config.checks # pyright: ignore[reportUnusedImport] # noqa: F401, PLC0415

59
config/checks.py Normal file
View file

@ -0,0 +1,59 @@
from __future__ import annotations
import shutil
from typing import TYPE_CHECKING
from django.conf import settings
from django.core.checks import Tags
from django.core.checks import Warning as DjangoWarning
from django.core.checks import register
if TYPE_CHECKING:
from django.core.checks import CheckMessage
REQUIRED_DEV_COMMANDS: tuple[str, ...] = (
"uname",
"curl",
"basename",
"grep",
"sort",
"tail",
"wget",
"unsquashfs",
"ssh-keygen",
"sudo",
"chown",
"truncate",
"mkfs.ext4",
"e2fsck",
)
@register(Tags.compatibility)
def check_required_dev_commands(*_: object, **__: object) -> list[CheckMessage]:
"""Warn in dev-like runtime when host tooling for init script is missing.
Returns:
A list of CheckMessage objects representing any issues found.
"""
if not (settings.DEBUG or getattr(settings, "TESTING", False)):
return []
missing_commands: list[str] = [
command for command in REQUIRED_DEV_COMMANDS if shutil.which(command) is None
]
if not missing_commands:
return []
return [
DjangoWarning(
"Missing host commands used by get_rootfs.bash.",
hint=(
"Dev-only tooling missing: "
f"{', '.join(missing_commands)}. "
"These are required for local VM/rootfs setup during development, not production runtime."
),
id="tussilago.W001",
),
]

View file

@ -69,6 +69,7 @@ if DEBUG:
INTERNAL_IPS: list[str] = ["127.0.0.1", "localhost"] INTERNAL_IPS: list[str] = ["127.0.0.1", "localhost"]
INSTALLED_APPS: list[str] = [ INSTALLED_APPS: list[str] = [
"config.apps.TussilagoConfig",
"django.contrib.admin", "django.contrib.admin",
"django.contrib.auth", "django.contrib.auth",
"django.contrib.contenttypes", "django.contrib.contenttypes",

View file

@ -4,11 +4,7 @@ version = "0.1.0"
description = "A platform to run and host applications, with a focus on Python applications." description = "A platform to run and host applications, with a focus on Python applications."
readme = "README.md" readme = "README.md"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = ["django>=6.0.4", "platformdirs>=4.9.6", "python-dotenv>=1.2.2"]
"django>=6.0.4",
"platformdirs>=4.9.6",
"python-dotenv>=1.2.2",
]
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
authors = [{ name = "Joakim Hellsén", email = "tlovinator@gmail.com" }] authors = [{ name = "Joakim Hellsén", email = "tlovinator@gmail.com" }]
@ -99,3 +95,4 @@ lint.ignore = [
DJANGO_SETTINGS_MODULE = "config.settings" DJANGO_SETTINGS_MODULE = "config.settings"
python_files = ["test_*.py", "*_test.py"] python_files = ["test_*.py", "*_test.py"]
addopts = "-n 5 -q" addopts = "-n 5 -q"
norecursedirs = ["Firecracker"]

View file

@ -0,0 +1,36 @@
from __future__ import annotations
from unittest.mock import patch
from django.test import override_settings
from config.checks import check_required_dev_commands
@override_settings(DEBUG=False, TESTING=False)
def test_command_check_is_silent_in_production_runtime() -> None:
"""Production runtime must not warn about dev-only command dependencies."""
with patch("config.checks.shutil.which", return_value=None):
warnings = check_required_dev_commands()
assert warnings == []
@override_settings(DEBUG=True, TESTING=False)
def test_command_check_warns_and_mentions_dev_only_dependencies() -> None:
"""Dev runtime must warn and clearly label these dependencies as dev-only."""
def fake_which(command: str) -> str | None:
if command == "curl":
return None
return f"/usr/bin/{command}"
with patch("config.checks.shutil.which", side_effect=fake_which):
warnings = check_required_dev_commands()
assert len(warnings) == 1
warning = warnings[0]
assert warning.id == "tussilago.W001"
assert warning.hint is not None
assert "Dev-only tooling missing" in warning.hint
assert "curl" in warning.hint

View file

@ -0,0 +1,35 @@
from __future__ import annotations
from io import StringIO
from unittest.mock import patch
from django.core.management import call_command
from django.test import override_settings
@override_settings(DEBUG=True, TESTING=False)
def test_dev_command_check_is_reported_by_check_command() -> None:
"""System check must be visible through call_command without direct check import."""
def fake_which(command: str) -> str | None:
if command == "curl":
return None
return f"/usr/bin/{command}"
stderr = StringIO()
with patch("shutil.which", side_effect=fake_which):
call_command("check", "-t", "compatibility", stderr=stderr)
output = stderr.getvalue()
assert "(tussilago.W001) Missing host commands used by get_rootfs.bash." in output
assert "Dev-only tooling missing: curl." in output
@override_settings(DEBUG=False, TESTING=False)
def test_dev_command_check_is_not_reported_in_production_runtime() -> None:
"""Production runtime must not report dev-only tooling warning."""
stderr = StringIO()
with patch("shutil.which", return_value=None):
call_command("check", "-t", "compatibility", stderr=stderr)
assert "(tussilago.W001)" not in stderr.getvalue()