microheart 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- microheart/__init__.py +5 -0
- microheart/api.py +31 -0
- microheart/assets.py +84 -0
- microheart/cli.py +85 -0
- microheart/data/Dockerfile +43 -0
- microheart/data/build-rootfs.sh +44 -0
- microheart/data/files/etc/conf.d/agetty.ttyS0 +4 -0
- microheart/data/files/etc/local.d/10-dns.start +4 -0
- microheart/data/files/etc/network/interfaces +8 -0
- microheart/net.py +38 -0
- microheart/vm.py +116 -0
- microheart-0.1.0.dist-info/METADATA +168 -0
- microheart-0.1.0.dist-info/RECORD +18 -0
- microheart-0.1.0.dist-info/WHEEL +5 -0
- microheart-0.1.0.dist-info/entry_points.txt +2 -0
- microheart-0.1.0.dist-info/licenses/LICENSE +21 -0
- microheart-0.1.0.dist-info/licenses/NOTICE +15 -0
- microheart-0.1.0.dist-info/top_level.txt +1 -0
microheart/__init__.py
ADDED
microheart/api.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Tiny client for the Firecracker API on a Unix socket (standard library only)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import http.client
|
|
5
|
+
import json
|
|
6
|
+
import socket
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _UnixConn(http.client.HTTPConnection):
|
|
10
|
+
def __init__(self, path: str, timeout: float = 5):
|
|
11
|
+
super().__init__("localhost", timeout=timeout)
|
|
12
|
+
self._path = path
|
|
13
|
+
|
|
14
|
+
def connect(self):
|
|
15
|
+
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
16
|
+
self.sock.settimeout(self.timeout)
|
|
17
|
+
self.sock.connect(self._path)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def request(sock_path: str, method: str, route: str, body: dict | None = None):
|
|
21
|
+
conn = _UnixConn(sock_path)
|
|
22
|
+
try:
|
|
23
|
+
data = json.dumps(body) if body is not None else None
|
|
24
|
+
conn.request(method, route, body=data, headers={"Content-Type": "application/json"})
|
|
25
|
+
resp = conn.getresponse()
|
|
26
|
+
raw = resp.read().decode()
|
|
27
|
+
if resp.status >= 300:
|
|
28
|
+
raise RuntimeError(f"{method} {route} -> {resp.status}: {raw}")
|
|
29
|
+
return json.loads(raw) if raw else None
|
|
30
|
+
finally:
|
|
31
|
+
conn.close()
|
microheart/assets.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Find the Firecracker binary and the guest kernel.
|
|
2
|
+
|
|
3
|
+
Order: files bundled in the wheel (microheart/bin), then the cache folder.
|
|
4
|
+
If neither exists, download them into the cache folder.
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import stat
|
|
11
|
+
import tarfile
|
|
12
|
+
import tempfile
|
|
13
|
+
import urllib.request
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
S3 = "https://s3.amazonaws.com/spec.ccfc.min"
|
|
17
|
+
RELEASES = "https://github.com/firecracker-microvm/firecracker/releases"
|
|
18
|
+
PKG = Path(__file__).parent
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def cache_dir() -> Path:
|
|
22
|
+
base = os.environ.get("MICROHEART_HOME") or os.path.join(
|
|
23
|
+
os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache")), "microheart")
|
|
24
|
+
p = Path(base)
|
|
25
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
return p
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _get(url: str) -> bytes:
|
|
30
|
+
with urllib.request.urlopen(url, timeout=60) as r:
|
|
31
|
+
return r.read()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _save(url: str, dest: Path) -> None:
|
|
35
|
+
with urllib.request.urlopen(url, timeout=120) as r, open(dest, "wb") as f:
|
|
36
|
+
shutil.copyfileobj(r, f)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _find(name_glob: str):
|
|
40
|
+
for d in (PKG / "bin", cache_dir()):
|
|
41
|
+
hits = sorted(d.glob(name_glob)) if d.is_dir() else []
|
|
42
|
+
if hits:
|
|
43
|
+
return hits[-1]
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def firecracker_path() -> Path:
|
|
48
|
+
found = _find("firecracker")
|
|
49
|
+
if found:
|
|
50
|
+
return found
|
|
51
|
+
arch = platform.machine()
|
|
52
|
+
latest_url = urllib.request.urlopen(f"{RELEASES}/latest", timeout=60).geturl()
|
|
53
|
+
tag = latest_url.rstrip("/").split("/")[-1]
|
|
54
|
+
dest = cache_dir() / "firecracker"
|
|
55
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
56
|
+
tgz = Path(tmp) / "fc.tgz"
|
|
57
|
+
_save(f"{RELEASES}/download/{tag}/firecracker-{tag}-{arch}.tgz", tgz)
|
|
58
|
+
with tarfile.open(tgz) as t:
|
|
59
|
+
t.extractall(tmp, filter="data") if hasattr(tarfile, "data_filter") else t.extractall(tmp)
|
|
60
|
+
src = Path(tmp) / f"release-{tag}-{arch}" / f"firecracker-{tag}-{arch}"
|
|
61
|
+
shutil.copy(src, dest)
|
|
62
|
+
dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
63
|
+
return dest
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def kernel_path() -> Path:
|
|
67
|
+
found = _find("vmlinux-*")
|
|
68
|
+
if found:
|
|
69
|
+
return found
|
|
70
|
+
arch = platform.machine()
|
|
71
|
+
top = _get(f"{S3}?list-type=2&prefix=firecracker-ci/&delimiter=/").decode()
|
|
72
|
+
prefixes = re.findall(r"<Prefix>(firecracker-ci/[0-9]{8}-[^/]+/)</Prefix>", top)
|
|
73
|
+
prefix = sorted(prefixes)[-1]
|
|
74
|
+
listing = _get(f"{S3}?list-type=2&prefix={prefix}{arch}/vmlinux-").decode()
|
|
75
|
+
keys = re.findall(rf"<Key>({re.escape(prefix)}{arch}/vmlinux-\d+\.\d+\.\d{{1,3}})</Key>", listing)
|
|
76
|
+
key = sorted(keys, key=lambda k: [int(x) for x in re.findall(r"\d+", k.rsplit("vmlinux-", 1)[1])])[-1]
|
|
77
|
+
dest = cache_dir() / key.rsplit("/", 1)[1]
|
|
78
|
+
_save(f"{S3}/{key}", dest)
|
|
79
|
+
return dest
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def recipe_dir() -> Path:
|
|
83
|
+
"""Folder with the Dockerfile and build-rootfs.sh."""
|
|
84
|
+
return PKG / "data"
|
microheart/cli.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Command line: microheart build-rootfs | fetch | up | ssh | status | console | down"""
|
|
2
|
+
import argparse
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import assets
|
|
9
|
+
from .vm import MicroVM
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _paths():
|
|
13
|
+
home = assets.cache_dir()
|
|
14
|
+
return home / "alpine.ext4", home / "vm_key"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _vm(a):
|
|
18
|
+
rootfs, key = _paths()
|
|
19
|
+
return MicroVM(a.rootfs or rootfs, a.key or key, vcpu=a.vcpu, mem_mib=a.mem)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def cmd_fetch(a):
|
|
23
|
+
print("firecracker:", assets.firecracker_path())
|
|
24
|
+
print("kernel: ", assets.kernel_path())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cmd_build(a):
|
|
28
|
+
rootfs, key = _paths()
|
|
29
|
+
if not key.exists():
|
|
30
|
+
subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-f", str(key), "-N", ""], check=True)
|
|
31
|
+
script = assets.recipe_dir() / "build-rootfs.sh"
|
|
32
|
+
subprocess.run(["bash", str(script), f"{key}.pub", a.size, str(a.output or rootfs)], check=True)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cmd_up(a):
|
|
36
|
+
secs = _vm(a).start()
|
|
37
|
+
print(f"VM is up ({secs:.1f} s). Try: sudo microheart ssh")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cmd_ssh(a):
|
|
41
|
+
vm = _vm(a)
|
|
42
|
+
os.execvp("ssh", ["ssh", "-i", vm.key, "-o", "StrictHostKeyChecking=no",
|
|
43
|
+
"-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR",
|
|
44
|
+
"root@172.16.0.2", *a.command])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cmd_status(a):
|
|
48
|
+
vm = _vm(a)
|
|
49
|
+
print(f"running (pid {vm.pid()})" if vm.running() else "stopped")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cmd_console(a):
|
|
53
|
+
print(_vm(a).tail(a.lines))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_down(a):
|
|
57
|
+
_vm(a).clean()
|
|
58
|
+
print("stopped; removed tap device and NAT rule")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main(argv=None):
|
|
62
|
+
p = argparse.ArgumentParser(prog="microheart")
|
|
63
|
+
p.add_argument("--rootfs"); p.add_argument("--key")
|
|
64
|
+
p.add_argument("--vcpu", type=int, default=1); p.add_argument("--mem", type=int, default=256)
|
|
65
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
66
|
+
sub.add_parser("fetch", help="download Firecracker and the kernel").set_defaults(f=cmd_fetch)
|
|
67
|
+
b = sub.add_parser("build-rootfs", help="build the ext4 disk from the Dockerfile (needs Docker, root)")
|
|
68
|
+
b.add_argument("--size", default="512M"); b.add_argument("--output", type=Path)
|
|
69
|
+
b.set_defaults(f=cmd_build)
|
|
70
|
+
sub.add_parser("up", help="boot the VM").set_defaults(f=cmd_up)
|
|
71
|
+
s = sub.add_parser("ssh", help="log in to the guest"); s.add_argument("command", nargs="*")
|
|
72
|
+
s.set_defaults(f=cmd_ssh)
|
|
73
|
+
sub.add_parser("status").set_defaults(f=cmd_status)
|
|
74
|
+
c = sub.add_parser("console"); c.add_argument("-n", "--lines", type=int, default=40)
|
|
75
|
+
c.set_defaults(f=cmd_console)
|
|
76
|
+
sub.add_parser("down", help="stop the VM and clean the network").set_defaults(f=cmd_down)
|
|
77
|
+
a = p.parse_args(argv)
|
|
78
|
+
try:
|
|
79
|
+
a.f(a)
|
|
80
|
+
except (PermissionError, RuntimeError, TimeoutError, subprocess.CalledProcessError) as e:
|
|
81
|
+
sys.exit(f"ERROR: {e}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
main()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Root file system for a Firecracker microVM.
|
|
2
|
+
# Base: Alpine Linux. Init system: OpenRC.
|
|
3
|
+
# This follows the recipe in docs/rootfs-and-kernel-setup.md (official Firecracker docs).
|
|
4
|
+
# Status: alpha, not yet tested on a real host.
|
|
5
|
+
|
|
6
|
+
FROM alpine:3.20
|
|
7
|
+
|
|
8
|
+
# Packages:
|
|
9
|
+
# openrc the init system (starts services at boot)
|
|
10
|
+
# util-linux gives the agetty program (serial console login)
|
|
11
|
+
# openssh the SSH server
|
|
12
|
+
# iproute2 the "ip" command
|
|
13
|
+
# curl, ca-certificates test tools for the network
|
|
14
|
+
RUN apk add --no-cache openrc util-linux openssh iproute2 curl ca-certificates
|
|
15
|
+
|
|
16
|
+
# Serial console login on ttyS0 (from the official doc).
|
|
17
|
+
RUN ln -s agetty /etc/init.d/agetty.ttyS0 \
|
|
18
|
+
&& echo ttyS0 > /etc/securetty
|
|
19
|
+
|
|
20
|
+
# Config files: network, DNS at boot, console options.
|
|
21
|
+
COPY files/ /
|
|
22
|
+
RUN chmod 755 /etc/local.d/10-dns.start
|
|
23
|
+
|
|
24
|
+
# SSH: allow root login with a key only. No passwords.
|
|
25
|
+
RUN echo "PermitRootLogin prohibit-password" >> /etc/ssh/sshd_config \
|
|
26
|
+
&& echo "PasswordAuthentication no" >> /etc/ssh/sshd_config
|
|
27
|
+
|
|
28
|
+
# Services.
|
|
29
|
+
# boot level: special file systems, host name, root file system, network.
|
|
30
|
+
# default level: console login, SSH, local scripts (DNS).
|
|
31
|
+
RUN rc-update add devfs boot \
|
|
32
|
+
&& rc-update add procfs boot \
|
|
33
|
+
&& rc-update add sysfs boot \
|
|
34
|
+
&& rc-update add hostname boot \
|
|
35
|
+
&& rc-update add bootmisc boot \
|
|
36
|
+
&& rc-update add root boot \
|
|
37
|
+
&& rc-update add networking boot \
|
|
38
|
+
&& rc-update add agetty.ttyS0 default \
|
|
39
|
+
&& rc-update add sshd default \
|
|
40
|
+
&& rc-update add local default
|
|
41
|
+
|
|
42
|
+
# No SSH key and no password go into this image.
|
|
43
|
+
# The build script adds the public key to the disk file after the build.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Build an ext4 disk file from the Dockerfile in this folder.
|
|
3
|
+
#
|
|
4
|
+
# Usage: sudo ./build-rootfs.sh <public-key-file> [size] [output-file]
|
|
5
|
+
# Example: sudo ./build-rootfs.sh ~/.cache/microheart/vm_key.pub 512M alpine.ext4
|
|
6
|
+
#
|
|
7
|
+
# Steps:
|
|
8
|
+
# 1. Docker builds the image and writes its files into a folder.
|
|
9
|
+
# 2. The script adds the SSH public key and the host name to the folder.
|
|
10
|
+
# 3. mkfs.ext4 -d copies the folder into a new ext4 file.
|
|
11
|
+
# 4. e2fsck checks the new file.
|
|
12
|
+
set -euo pipefail
|
|
13
|
+
|
|
14
|
+
PUBKEY="${1:?Give the path of a public key file (ends in .pub)}"
|
|
15
|
+
SIZE="${2:-512M}"
|
|
16
|
+
OUT="${3:-alpine.ext4}"
|
|
17
|
+
|
|
18
|
+
[ -f "$PUBKEY" ] || { echo "ERROR: $PUBKEY does not exist"; exit 1; }
|
|
19
|
+
[ "$(id -u)" -eq 0 ] || { echo "ERROR: run this script as root"; exit 1; }
|
|
20
|
+
|
|
21
|
+
WORK="$(mktemp -d)"
|
|
22
|
+
trap 'rm -rf "$WORK"' EXIT
|
|
23
|
+
|
|
24
|
+
echo "Step 1: build the image and write its files to a folder"
|
|
25
|
+
# --output type=local writes the image files straight to a folder.
|
|
26
|
+
# It avoids the empty /etc/hostname, /etc/hosts and /etc/resolv.conf
|
|
27
|
+
# that "docker export" can leave behind. Verify this on your Docker version.
|
|
28
|
+
docker build --output "type=local,dest=$WORK/root" "$(dirname "$0")"
|
|
29
|
+
|
|
30
|
+
echo "Step 2: add the SSH public key and the host name"
|
|
31
|
+
install -d -m 700 "$WORK/root/root/.ssh"
|
|
32
|
+
install -m 600 "$PUBKEY" "$WORK/root/root/.ssh/authorized_keys"
|
|
33
|
+
echo "fc-alpine" > "$WORK/root/etc/hostname"
|
|
34
|
+
printf '127.0.0.1 localhost\n' > "$WORK/root/etc/hosts"
|
|
35
|
+
mkdir -p "$WORK/root"/{dev,proc,sys,run,tmp,var}
|
|
36
|
+
|
|
37
|
+
echo "Step 3: make the ext4 file ($SIZE)"
|
|
38
|
+
rm -f "$OUT"
|
|
39
|
+
truncate -s "$SIZE" "$OUT"
|
|
40
|
+
mkfs.ext4 -q -d "$WORK/root" -F "$OUT"
|
|
41
|
+
|
|
42
|
+
echo "Step 4: check the file system"
|
|
43
|
+
e2fsck -fn "$OUT" > /dev/null && echo "OK: $OUT is a valid ext4 file system"
|
|
44
|
+
ls -lh "$OUT"
|
microheart/net.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Host network: one TAP device and one NAT rule. Needs root."""
|
|
2
|
+
import json
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _run(*cmd, check=True, capture=False):
|
|
7
|
+
return subprocess.run(cmd, check=check, text=True,
|
|
8
|
+
stdout=subprocess.PIPE if capture else None,
|
|
9
|
+
stderr=subprocess.DEVNULL if not check else None)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def host_iface() -> str:
|
|
13
|
+
out = _run("ip", "-j", "route", "list", "default", capture=True).stdout
|
|
14
|
+
return json.loads(out)[0]["dev"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def up(tap: str, host_ip: str) -> str:
|
|
18
|
+
if _run("ip", "link", "show", tap, check=False).returncode != 0:
|
|
19
|
+
_run("ip", "tuntap", "add", "dev", tap, "mode", "tap")
|
|
20
|
+
if f"{host_ip}/30" not in _run("ip", "addr", "show", "dev", tap, capture=True).stdout:
|
|
21
|
+
_run("ip", "addr", "add", f"{host_ip}/30", "dev", tap)
|
|
22
|
+
_run("ip", "link", "set", "dev", tap, "up")
|
|
23
|
+
_run("sysctl", "-q", "-w", "net.ipv4.ip_forward=1")
|
|
24
|
+
_run("iptables", "-P", "FORWARD", "ACCEPT")
|
|
25
|
+
iface = host_iface()
|
|
26
|
+
if _run("iptables", "-t", "nat", "-C", "POSTROUTING", "-o", iface, "-j", "MASQUERADE",
|
|
27
|
+
check=False).returncode != 0:
|
|
28
|
+
_run("iptables", "-t", "nat", "-A", "POSTROUTING", "-o", iface, "-j", "MASQUERADE")
|
|
29
|
+
return iface
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def down(tap: str) -> None:
|
|
33
|
+
_run("ip", "link", "del", tap, check=False)
|
|
34
|
+
try:
|
|
35
|
+
iface = host_iface()
|
|
36
|
+
except (IndexError, subprocess.CalledProcessError):
|
|
37
|
+
return
|
|
38
|
+
_run("iptables", "-t", "nat", "-D", "POSTROUTING", "-o", iface, "-j", "MASQUERADE", check=False)
|
microheart/vm.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""MicroVM: build a config file, boot Firecracker, reach the guest over SSH."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import api, assets, net
|
|
10
|
+
|
|
11
|
+
GUEST_IP = "172.16.0.2" # fixed in the rootfs: data/files/etc/network/interfaces
|
|
12
|
+
HOST_IP = "172.16.0.1"
|
|
13
|
+
GUEST_MAC = "06:00:AC:10:00:02"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MicroVM:
|
|
17
|
+
def __init__(self, rootfs, key, vcpu=1, mem_mib=256, tap="tap0", run_dir=None):
|
|
18
|
+
self.rootfs = str(Path(rootfs).resolve())
|
|
19
|
+
self.key = str(Path(key).resolve())
|
|
20
|
+
self.vcpu, self.mem, self.tap = vcpu, mem_mib, tap
|
|
21
|
+
self.run_dir = Path(run_dir or assets.cache_dir() / "run")
|
|
22
|
+
self.run_dir.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
self.sock = str(self.run_dir / "firecracker.socket")
|
|
24
|
+
self.pidfile = self.run_dir / "firecracker.pid"
|
|
25
|
+
self.console = self.run_dir / "console.log"
|
|
26
|
+
|
|
27
|
+
# -- state ---------------------------------------------------------
|
|
28
|
+
def pid(self):
|
|
29
|
+
try:
|
|
30
|
+
pid = int(self.pidfile.read_text())
|
|
31
|
+
os.kill(pid, 0)
|
|
32
|
+
return pid
|
|
33
|
+
except (FileNotFoundError, ValueError, ProcessLookupError):
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
def running(self) -> bool:
|
|
37
|
+
return self.pid() is not None
|
|
38
|
+
|
|
39
|
+
# -- lifecycle -----------------------------------------------------
|
|
40
|
+
def config(self) -> dict:
|
|
41
|
+
return {
|
|
42
|
+
"boot-source": {"kernel_image_path": str(assets.kernel_path()),
|
|
43
|
+
"boot_args": "console=ttyS0 reboot=k panic=1"},
|
|
44
|
+
"drives": [{"drive_id": "rootfs", "path_on_host": self.rootfs,
|
|
45
|
+
"is_root_device": True, "is_read_only": False}],
|
|
46
|
+
"network-interfaces": [{"iface_id": "net1", "guest_mac": GUEST_MAC,
|
|
47
|
+
"host_dev_name": self.tap}],
|
|
48
|
+
"machine-config": {"vcpu_count": self.vcpu, "mem_size_mib": self.mem},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
def start(self, wait=60):
|
|
52
|
+
if os.geteuid() != 0:
|
|
53
|
+
raise PermissionError("run as root (use sudo)")
|
|
54
|
+
if not os.access("/dev/kvm", os.R_OK | os.W_OK):
|
|
55
|
+
raise PermissionError("no access to /dev/kvm")
|
|
56
|
+
if self.running():
|
|
57
|
+
return
|
|
58
|
+
fc = assets.firecracker_path()
|
|
59
|
+
net.up(self.tap, HOST_IP)
|
|
60
|
+
cfg = self.run_dir / "vm_config.json"
|
|
61
|
+
cfg.write_text(json.dumps(self.config(), indent=2))
|
|
62
|
+
Path(self.sock).unlink(missing_ok=True)
|
|
63
|
+
with open(self.console, "w") as log:
|
|
64
|
+
proc = subprocess.Popen([str(fc), "--api-sock", self.sock, "--config-file", str(cfg),
|
|
65
|
+
"--enable-pci"], stdout=log, stderr=subprocess.STDOUT,
|
|
66
|
+
stdin=subprocess.DEVNULL, start_new_session=True)
|
|
67
|
+
self.pidfile.write_text(str(proc.pid))
|
|
68
|
+
t0 = time.time()
|
|
69
|
+
while time.time() - t0 < wait:
|
|
70
|
+
if proc.poll() is not None:
|
|
71
|
+
raise RuntimeError("Firecracker stopped:\n" + self.tail())
|
|
72
|
+
if self.ssh("true", check=False).returncode == 0:
|
|
73
|
+
return time.time() - t0
|
|
74
|
+
time.sleep(1)
|
|
75
|
+
raise TimeoutError(f"no SSH after {wait} s:\n" + self.tail())
|
|
76
|
+
|
|
77
|
+
def stop(self):
|
|
78
|
+
pid = self.pid()
|
|
79
|
+
if pid:
|
|
80
|
+
self.ssh("reboot", check=False) # a guest reboot makes Firecracker exit
|
|
81
|
+
for _ in range(10):
|
|
82
|
+
if not self.running():
|
|
83
|
+
break
|
|
84
|
+
time.sleep(1)
|
|
85
|
+
if self.running():
|
|
86
|
+
os.kill(pid, signal.SIGTERM)
|
|
87
|
+
self.pidfile.unlink(missing_ok=True)
|
|
88
|
+
Path(self.sock).unlink(missing_ok=True)
|
|
89
|
+
|
|
90
|
+
def clean(self):
|
|
91
|
+
self.stop()
|
|
92
|
+
net.down(self.tap)
|
|
93
|
+
|
|
94
|
+
# -- guest access --------------------------------------------------
|
|
95
|
+
def ssh(self, command: str, check=True, timeout=30):
|
|
96
|
+
return subprocess.run(
|
|
97
|
+
["ssh", "-i", self.key, "-o", "StrictHostKeyChecking=no",
|
|
98
|
+
"-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR",
|
|
99
|
+
"-o", "ConnectTimeout=3", f"root@{GUEST_IP}", command],
|
|
100
|
+
check=check, text=True, capture_output=True, timeout=timeout)
|
|
101
|
+
|
|
102
|
+
def machine_config(self):
|
|
103
|
+
return api.request(self.sock, "GET", "/machine-config")
|
|
104
|
+
|
|
105
|
+
def tail(self, n=30) -> str:
|
|
106
|
+
try:
|
|
107
|
+
return "\n".join(self.console.read_text().splitlines()[-n:])
|
|
108
|
+
except FileNotFoundError:
|
|
109
|
+
return ""
|
|
110
|
+
|
|
111
|
+
def __enter__(self):
|
|
112
|
+
self.start()
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
def __exit__(self, *exc):
|
|
116
|
+
self.clean()
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: microheart
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Boot Firecracker microVMs from Python or the command line. Zero dependencies, Linux/KVM, Docker only to build the disk.
|
|
5
|
+
Author: Ajay Kumar
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Aj7Ay/microheart
|
|
8
|
+
Project-URL: Source, https://github.com/Aj7Ay/microheart
|
|
9
|
+
Project-URL: Issues, https://github.com/Aj7Ay/microheart/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/Aj7Ay/microheart/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: firecracker,microvm,kvm,virtualization,sandbox,vm,isolation
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: System :: Emulators
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
License-File: NOTICE
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# microheart
|
|
30
|
+
|
|
31
|
+
**Boot a [Firecracker](https://firecracker-microvm.github.io/) microVM with one command or three lines of Python.**
|
|
32
|
+
No Python dependencies. Linux with KVM only.
|
|
33
|
+
|
|
34
|
+
> **Unofficial. Not affiliated with or endorsed by Amazon Web Services.** "Firecracker" is an AWS project and name.
|
|
35
|
+
>
|
|
36
|
+
> **Status: alpha.** The code has not been tested on a real host yet. Test it before you rely on it.
|
|
37
|
+
> See [SECURITY.md](SECURITY.md) for the known limits.
|
|
38
|
+
|
|
39
|
+
## What it does
|
|
40
|
+
|
|
41
|
+
Firecracker is a very small virtual machine monitor (3.6 MB). It starts a Linux guest in about a second,
|
|
42
|
+
but you must do many manual steps first: get the binary, get a kernel, build a disk, make a network
|
|
43
|
+
device, set NAT, write the API calls. microheart does these steps for you.
|
|
44
|
+
|
|
45
|
+
| Step | By hand | With microheart |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| Firecracker binary | download, unpack | `fetch` (or bundled in the wheel) |
|
|
48
|
+
| Guest kernel | find on S3, download | `fetch` (or bundled in the wheel) |
|
|
49
|
+
| Root disk | Docker image, export, `mkfs.ext4` | `build-rootfs` |
|
|
50
|
+
| Network | `ip tuntap`, `iptables`, forwarding | `up` |
|
|
51
|
+
| Boot | 5 curl calls to the API socket | `up` (one `--config-file`) |
|
|
52
|
+
| Login | key, route, DNS inside the guest | key, route and DNS are in the disk |
|
|
53
|
+
| Clean up | remove TAP and NAT rule | `down` |
|
|
54
|
+
|
|
55
|
+
## Requirements
|
|
56
|
+
|
|
57
|
+
**To run a VM**
|
|
58
|
+
|
|
59
|
+
- Linux, `x86_64` (or `aarch64`), with `/dev/kvm` readable and writable by root
|
|
60
|
+
- Root access (`sudo`): TAP devices and `iptables` need it
|
|
61
|
+
- `ip`, `iptables`, `sysctl`, `ssh` (normally present)
|
|
62
|
+
- Python 3.9 or newer
|
|
63
|
+
- A virtualization-capable host. A cloud VM works if nested virtualization is on (tested idea: DigitalOcean droplet)
|
|
64
|
+
|
|
65
|
+
**To build the root disk (once, on any machine)**
|
|
66
|
+
|
|
67
|
+
- Docker, `mkfs.ext4`, `e2fsck`, `ssh-keygen`
|
|
68
|
+
|
|
69
|
+
Docker is **not** needed to run the VM. Build the disk once and copy the `.ext4` file.
|
|
70
|
+
|
|
71
|
+
## Install
|
|
72
|
+
|
|
73
|
+
pip install microheart
|
|
74
|
+
|
|
75
|
+
## Quick start
|
|
76
|
+
|
|
77
|
+
sudo microheart fetch # Firecracker + kernel (skipped when bundled in the wheel)
|
|
78
|
+
sudo microheart build-rootfs # build alpine.ext4 + an SSH key (needs Docker)
|
|
79
|
+
sudo microheart up # boot the VM and wait for SSH
|
|
80
|
+
sudo microheart ssh uname -a # run one command, or open a shell without arguments
|
|
81
|
+
sudo microheart down # stop the VM, remove tap0 and the NAT rule
|
|
82
|
+
|
|
83
|
+
Files go to `~/.cache/microheart/` (change with `MICROHEART_HOME`). With `sudo`, `~` is root's home.
|
|
84
|
+
|
|
85
|
+
## Command reference
|
|
86
|
+
|
|
87
|
+
| Command | What it does |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `microheart fetch` | Get the Firecracker binary and the newest CI kernel into the cache. |
|
|
90
|
+
| `microheart build-rootfs [--size 512M] [--output FILE]` | Build the ext4 disk from the bundled `Dockerfile`. Make an ed25519 key if none exists. |
|
|
91
|
+
| `microheart up` | Make `tap0`, set NAT, write the config file, start Firecracker, wait for SSH. |
|
|
92
|
+
| `microheart ssh [command...]` | SSH to the guest at `172.16.0.2` with the generated key. |
|
|
93
|
+
| `microheart status` | Print `running (pid N)` or `stopped`. |
|
|
94
|
+
| `microheart console [-n 40]` | Show the last lines of the guest console. |
|
|
95
|
+
| `microheart down` | Stop the VM and remove the TAP device and NAT rule. |
|
|
96
|
+
|
|
97
|
+
Global options (before the command): `--rootfs FILE`, `--key FILE`, `--vcpu N` (default 1), `--mem MIB` (default 256).
|
|
98
|
+
|
|
99
|
+
## Python API
|
|
100
|
+
|
|
101
|
+
from microheart import MicroVM
|
|
102
|
+
|
|
103
|
+
with MicroVM("alpine.ext4", "vm_key", vcpu=2, mem_mib=512) as vm:
|
|
104
|
+
print(vm.ssh("uname -a").stdout)
|
|
105
|
+
|
|
106
|
+
| Member | Meaning |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `MicroVM(rootfs, key, vcpu=1, mem_mib=256, tap="tap0", run_dir=None)` | Describe a VM. Nothing starts yet. |
|
|
109
|
+
| `.start(wait=60)` | Boot. Return the seconds until SSH worked. Needs root. |
|
|
110
|
+
| `.stop()` | Ask the guest to reboot (Firecracker then exits). Kill it after 10 s. |
|
|
111
|
+
| `.clean()` | `stop()` plus remove the TAP device and NAT rule. |
|
|
112
|
+
| `.ssh(cmd, check=True, timeout=30)` | Run a command in the guest. Return `subprocess.CompletedProcess`. |
|
|
113
|
+
| `.running()`, `.pid()` | State of the Firecracker process. |
|
|
114
|
+
| `.machine_config()` | Read `/machine-config` from the Firecracker API. |
|
|
115
|
+
| `.tail(n)` | Last `n` console lines. |
|
|
116
|
+
|
|
117
|
+
## How it works
|
|
118
|
+
|
|
119
|
+
1. `assets.py` finds the Firecracker binary and kernel (bundled, then cache, then download).
|
|
120
|
+
2. `net.py` makes `tap0` with host IP `172.16.0.1/30`, turns on IP forwarding and adds `MASQUERADE`.
|
|
121
|
+
3. `vm.py` writes `vm_config.json` and starts `firecracker --config-file ... --enable-pci`.
|
|
122
|
+
4. The guest MAC `06:00:AC:10:00:02` and the disk's `/etc/network/interfaces` give the guest `172.16.0.2/30`
|
|
123
|
+
with gateway `172.16.0.1`. DNS (`8.8.8.8`) is written at boot.
|
|
124
|
+
5. microheart polls SSH until the guest answers.
|
|
125
|
+
|
|
126
|
+
Network layout:
|
|
127
|
+
|
|
128
|
+
guest eth0 172.16.0.2/30 <--virtio--> host tap0 172.16.0.1/30 --NAT--> host default interface --> internet
|
|
129
|
+
|
|
130
|
+
One VM at a time for now: the IP addresses and `tap0` are fixed.
|
|
131
|
+
|
|
132
|
+
## Root disk recipe
|
|
133
|
+
|
|
134
|
+
`src/microheart/data/` holds the recipe:
|
|
135
|
+
|
|
136
|
+
- `Dockerfile`: Alpine 3.20, OpenRC, OpenSSH, iproute2, curl. SSH is key-only.
|
|
137
|
+
- `files/`: network config, DNS at boot, serial console options.
|
|
138
|
+
- `build-rootfs.sh`: Docker image to folder, add your public key, `mkfs.ext4 -d`, `e2fsck`.
|
|
139
|
+
|
|
140
|
+
Edit the recipe to add your own packages, then run `build-rootfs` again.
|
|
141
|
+
|
|
142
|
+
## Security notes
|
|
143
|
+
|
|
144
|
+
- The serial console logs in as **root with no password** (test setting).
|
|
145
|
+
Edit `data/files/etc/conf.d/agetty.ttyS0` before real use.
|
|
146
|
+
- SSH accepts keys only. The key is made on your machine and is never part of the package.
|
|
147
|
+
- The guest can reach the internet through host NAT. The guest and host `FORWARD` policy is set to `ACCEPT`.
|
|
148
|
+
- Firecracker's `jailer` (extra isolation) is not used yet.
|
|
149
|
+
- microheart runs as root and changes host network settings. Read the code first.
|
|
150
|
+
|
|
151
|
+
## Limits and roadmap
|
|
152
|
+
|
|
153
|
+
- Alpha, untested on a real host.
|
|
154
|
+
- One VM at a time (fixed addresses).
|
|
155
|
+
- No snapshots, no jailer, no multi-VM networking.
|
|
156
|
+
- Planned: per-VM addresses, `--config-file` options, snapshots, jailer support, `aarch64` tests.
|
|
157
|
+
|
|
158
|
+
## Development
|
|
159
|
+
|
|
160
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). Build a wheel with the binary and kernel inside:
|
|
161
|
+
|
|
162
|
+
python scripts/bundle.py && python -m build --wheel
|
|
163
|
+
wheel tags --platform-tag manylinux_2_17_x86_64 --remove dist/*.whl
|
|
164
|
+
|
|
165
|
+
## License
|
|
166
|
+
|
|
167
|
+
MIT, see [LICENSE](LICENSE). Third-party software (Firecracker: Apache-2.0; Linux kernel: GPL-2.0) is
|
|
168
|
+
downloaded or bundled unchanged. See [NOTICE](NOTICE).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
microheart/__init__.py,sha256=TN286FNtwWb_Bb9HeqBXWMzmYvi9OEhVv9I3KOpMeng,141
|
|
2
|
+
microheart/api.py,sha256=tPn1LEB3ccdcjgYQq2p_xE3iTu7Xi0VnhsajsWOpxOU,1059
|
|
3
|
+
microheart/assets.py,sha256=Zt2DGGrjAur9KmWZ4gbIh-YGUsM0MxP8kOZcoVGBvyw,2814
|
|
4
|
+
microheart/cli.py,sha256=7syV-RaCXl0oh8-NJwmeO6WZ3N4ikrvZsuwWk5axoZ4,2776
|
|
5
|
+
microheart/net.py,sha256=JqYB-2RtG-iCY8khArk0pbiXaM79NYbsw0308J5ndX0,1516
|
|
6
|
+
microheart/vm.py,sha256=U1L9VerhKbyrL_9klkU4Te8yniK-FL6aADFMOThkKS8,4526
|
|
7
|
+
microheart/data/Dockerfile,sha256=V47mywLH9ydLDB7MStpfY3TAtqzfBj1Pz4jQtNLDWSY,1628
|
|
8
|
+
microheart/data/build-rootfs.sh,sha256=dB61FvUoOZtAzdojm7blg0_fGoYpgX_d0khI6X8Jc6k,1716
|
|
9
|
+
microheart/data/files/etc/conf.d/agetty.ttyS0,sha256=3udcD3K8tkFb6Ec5G0sdyCq7mk-p8lljKf9Mi8k32sM,259
|
|
10
|
+
microheart/data/files/etc/local.d/10-dns.start,sha256=_Q_sXI_FbEbdQPzaS_iT1MRJUTlj9j_hBWm8An_5q2s,208
|
|
11
|
+
microheart/data/files/etc/network/interfaces,sha256=VH1a7OiODsMVvhkDspaXLZ40wJYPJcS8aeeucR6spec,139
|
|
12
|
+
microheart-0.1.0.dist-info/licenses/LICENSE,sha256=tklI3ZsFSOkX2eGqZX0LY8vjmXlnLiG8D0VMCNRSGz4,1067
|
|
13
|
+
microheart-0.1.0.dist-info/licenses/NOTICE,sha256=NLbShw5GOKUAXVjYuficbxhmo8VdYrmEj-v3vVFqWgg,689
|
|
14
|
+
microheart-0.1.0.dist-info/METADATA,sha256=dh59z02lKsNMxcZ3Xo0n1F71WsBYlcibs5dI7i_BA-U,7523
|
|
15
|
+
microheart-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
16
|
+
microheart-0.1.0.dist-info/entry_points.txt,sha256=uxx7fvBsRL-mEfxB-fnKzTTxaWSY27B6L2id5hBQlrs,51
|
|
17
|
+
microheart-0.1.0.dist-info/top_level.txt,sha256=p_fGLyMFgSFpkdQJXDcm4aUu4JIspJvU26BPozkAcIU,11
|
|
18
|
+
microheart-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ajay Kumar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
microheart
|
|
2
|
+
Copyright (c) 2026 Ajay Kumar. Licensed under the MIT License (see LICENSE).
|
|
3
|
+
|
|
4
|
+
This project downloads (or, in platform wheels, bundles) unmodified third-party software:
|
|
5
|
+
|
|
6
|
+
Firecracker - Apache License 2.0
|
|
7
|
+
Copyright Amazon.com, Inc. or its affiliates.
|
|
8
|
+
https://github.com/firecracker-microvm/firecracker
|
|
9
|
+
|
|
10
|
+
Linux guest kernel images from the Firecracker CI artifacts - GPL-2.0
|
|
11
|
+
Source: https://github.com/firecracker-microvm/firecracker
|
|
12
|
+
and https://www.kernel.org/
|
|
13
|
+
|
|
14
|
+
"Firecracker" is a name of Amazon Web Services. microheart is an independent project.
|
|
15
|
+
It is not affiliated with, endorsed by, or sponsored by AWS.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
microheart
|