├── .gitignore ├── .gitmodules ├── .tokeignore ├── Dockerfile.network ├── Dockerfile.ping ├── Dockerfile.shell ├── README.md ├── boot ├── configure_vm.py ├── init-shim ├── go.mod ├── go.sum └── init-tpl.go ├── land ├── lib.sh ├── setup-network └── vm_config.example.json /.gitignore: -------------------------------------------------------------------------------- 1 | /work 2 | /rootfs.ext4 3 | /tar2ext4 4 | /init 5 | /init-shim/init.go* 6 | /kernel.bin 7 | /.vscode 8 | /vm_config.json 9 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "hcsshim"] 2 | path = hcsshim 3 | url = git@github.com:microsoft/hcsshim.git 4 | branch = main 5 | -------------------------------------------------------------------------------- /.tokeignore: -------------------------------------------------------------------------------- 1 | /hcsshim 2 | -------------------------------------------------------------------------------- /Dockerfile.network: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | 3 | RUN apk add -U bash bind-tools busybox-extras curl \ 4 | iproute2 iputils jq mtr \ 5 | net-tools dhcpcd \ 6 | perl-net-telnet procps tcpdump tcptraceroute wget 7 | 8 | ENTRYPOINT ["/bin/bash"] 9 | # CMD ["/bin/bash"] 10 | -------------------------------------------------------------------------------- /Dockerfile.ping: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | 3 | CMD ["sh"] 4 | -------------------------------------------------------------------------------- /Dockerfile.shell: -------------------------------------------------------------------------------- 1 | FROM ubuntu:latest 2 | 3 | CMD ["bash"] 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # land 2 | 3 | *(5 hours and 165 loc to get the mvp!)* 4 | 5 | docker image -> firecracker vm! 6 | 7 | If you need this in a less-janky form, consider using [peckish](https://github.com/queer/peckish) to convert Docker images, tarballs, and more to ext4 images. 8 | 9 | ## go 10 | 11 | ```bash 12 | # build the vm 13 | ./land 14 | # create your config file 15 | ./configure_vm.py # or just copy + edit vm_config.example.json 16 | # launch the vm! 17 | ./boot [my_vm_config.json] # ./vm_config.json is the default 18 | # TODO: networkng goes here 19 | ``` 20 | 21 | ### deps 22 | 23 | - golang ;-; 24 | - required for building tar2ext4 25 | - git 26 | - hopefully obvious, but it's for submodules 27 | - jq 28 | - used for parsing docker manifests 29 | - e2fsprogs 30 | - used for building the final rootfs image 31 | - firecracker 32 | - run the vm! :D 33 | 34 | ## how does it work? 35 | 36 | - extract layers + metadata from docker 37 | - rebuild layers into one rootfs 38 | - convert the rootfs tarball into an ext4 disk image 39 | - mount the rootfs image as the vm rootfs 40 | - :tada: 41 | -------------------------------------------------------------------------------- /boot: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | source ./lib.sh 6 | 7 | log "booting firecracker..." 8 | firecracker --no-api --config-file ./vm_config.json # TODO: Should we be providing a socket here anyway? 9 | -------------------------------------------------------------------------------- /configure_vm.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | import sys 5 | 6 | 7 | def spacer(times=2): 8 | for _ in range(times): 9 | print() 10 | 11 | 12 | def boolify(s): 13 | if s in ["True", "true", "1", "y", "t"]: 14 | return True 15 | elif s in ["False", "false", "0", "n", "f"]: 16 | return False 17 | else: 18 | return False 19 | 20 | 21 | def die(check, msg): 22 | if check: 23 | print(msg) 24 | sys.exit(1) 25 | 26 | 27 | print("hello! let's configure your new firecracker vm together :D") 28 | spacer(3) 29 | 30 | print("first, we need to know how many vcpus you want give the vm") 31 | print("this is just a number (1, 7, 4, etc)") 32 | vcpus = input("vcpus = ") 33 | die(not vcpus.isdigit(), "vcpus must be a number") 34 | spacer() 35 | 36 | print("next, we need to know how much memory you want to give the vm") 37 | print("this is the number of mib you want to assign (256, 512, 2048, etc)") 38 | memory = input("memory = ") 39 | die(not memory.isdigit(), "memory must be a number") 40 | spacer() 41 | 42 | print("do you want to enable smt? (y/n) (default: n)") 43 | smt = boolify(input("smt = ")) 44 | spacer() 45 | 46 | print("where is your kernel located? (default: ./kernel.bin)") 47 | kernel = input("kernel = ") 48 | if kernel == "": 49 | print("using default kernel location!") 50 | kernel = "./kernel.bin" 51 | spacer() 52 | 53 | print("where is your rootfs located? (default: ./rootfs.ext4)") 54 | rootfs = input("rootfs = ") 55 | if rootfs == "": 56 | print("using default rootfs location!") 57 | rootfs = "./rootfs.ext4" 58 | spacer() 59 | 60 | print("do you want to pass a custom kernel cmdline?") 61 | default_cmdline = "console=ttyS0 reboot=k panic=1 pci=off" 62 | print(f"default: {default_cmdline}") 63 | cmdline = input("cmdline = ") 64 | if cmdline == "": 65 | print("using default kernel cmdline!") 66 | cmdline = default_cmdline 67 | spacer() 68 | 69 | print("good job! :D") 70 | 71 | config = { 72 | "boot-source": { 73 | "kernel_image_path": kernel, 74 | "boot_args": cmdline, 75 | "initrd_path": None, 76 | }, 77 | "drives": [ 78 | { 79 | "drive_id": "rootfs", 80 | "path_on_host": rootfs, 81 | "is_root_device": True, 82 | "is_read_only": False, 83 | } 84 | ], 85 | "machine-config": { 86 | "vcpu_count": int(vcpus), 87 | "mem_size_mib": int(memory), 88 | "smt": smt, 89 | "track_dirty_pages": False, 90 | }, 91 | "network-interfaces": [{"iface_id": "eth0", "host_dev_name": "tap0"}], 92 | } 93 | 94 | with open("./vm_config.json", "w") as f: 95 | json.dump(config, f, indent=2) 96 | 97 | print("your vm config has been written to ./vm_config.json") 98 | -------------------------------------------------------------------------------- /init-shim/go.mod: -------------------------------------------------------------------------------- 1 | module example.com/fuck 2 | 3 | go 1.18 4 | 5 | require github.com/vishvananda/netlink v1.1.0 6 | 7 | require ( 8 | github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df // indirect 9 | golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f // indirect 10 | ) 11 | -------------------------------------------------------------------------------- /init-shim/go.sum: -------------------------------------------------------------------------------- 1 | github.com/vishvananda/netlink v1.1.0 h1:1iyaYNBLmP6L0220aDnYQpo1QEV4t4hJ+xEEhhJH8j0= 2 | github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= 3 | github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df h1:OviZH7qLw/7ZovXvuNyL3XQl8UFofeikI1NW1Gypu7k= 4 | github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= 5 | golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 6 | golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f h1:8w7RhxzTVgUzw/AH/9mUV5q0vMgy40SQRursCcfmkCw= 7 | golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 8 | -------------------------------------------------------------------------------- /init-shim/init-tpl.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | "syscall" 10 | 11 | "github.com/vishvananda/netlink" 12 | ) 13 | 14 | func main() { 15 | fmt.Println("init: starting up!") 16 | exe := "%EXE%" 17 | finalArgs := []string{"%ARGS%"} 18 | finalEnv := []string{"%ENV%"} 19 | entrypoint := "%ENTRYPOINT%" 20 | entrypointArgs := []string{"%ENTRYPOINT_ARGS%"} 21 | 22 | // Set up initial path if provided 23 | for _, arg := range finalEnv { 24 | if strings.HasPrefix(arg, "PATH=") { 25 | path := arg[5:] 26 | os.Setenv("PATH", path) 27 | // Trick the shell into being usable if you boot one 28 | fmt.Println("init: set PATH =", path) 29 | break 30 | } 31 | } 32 | 33 | fmt.Println("init: enabling eth0") 34 | eth0, err := netlink.LinkByName("eth0") 35 | failFast(err, "netlink get eth0") 36 | err = netlink.LinkSetUp(eth0) 37 | failFast(err, "netlink set eth0 up") 38 | 39 | fmt.Println("init: adding eth0 ip") 40 | addr, err := netlink.ParseAddr("172.22.0.2/16") 41 | addr.Broadcast = net.IPv4(172, 22, 255, 255) 42 | failFast(err, "netlink parse addr") 43 | err = netlink.AddrAdd(eth0, addr) 44 | failFast(err, "netlink add addr") 45 | 46 | fmt.Println("init: adding default route") 47 | route := &netlink.Route{ 48 | Scope: netlink.SCOPE_UNIVERSE, 49 | Dst: nil, 50 | Gw: net.IPv4(172, 22, 0, 1), 51 | } 52 | err = netlink.RouteAdd(route) 53 | failFast(err, "netlink add route") 54 | 55 | // Run entrypoint 56 | if entrypoint != "" { 57 | cmdArgs := append([]string{exe}, finalArgs...) 58 | entrypointArgs = append(entrypointArgs, cmdArgs...) 59 | fmt.Println("init: entrypoint: exe =", entrypoint, "args =", entrypointArgs, "env =", finalEnv) 60 | // entrypointExec := exec.Command(entrypoint, entrypointArgs...) 61 | // entrypointExec.Env = finalEnv 62 | 63 | // err = entrypointExec.Run() 64 | err = syscall.Exec(entrypoint, entrypointArgs, finalEnv) 65 | failFast(err, "entrypoint exec") 66 | } else { 67 | // Look up the exe since syscall.Exec doesn't 68 | exe, err := exec.LookPath(exe) 69 | failFast(err, "cmd exe resolve") 70 | 71 | // Run cmd 72 | fmt.Println("init: cmd: exe =", exe, "args =", finalArgs, "env =", finalEnv) 73 | err = syscall.Exec(exe, append([]string{exe}, finalArgs...), finalEnv) 74 | failFast(err, "cmd exec") 75 | } 76 | } 77 | 78 | func failFast(err error, ctx string) { 79 | if err != nil { 80 | fmt.Println("init:", ctx, "--", err.Error()) 81 | os.Exit(1) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /land: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # tar2ext4 from https://github.com/microsoft/hcsshim 4 | 5 | set -euo pipefail 6 | 7 | source ./lib.sh 8 | 9 | function cleanup() { 10 | log "cleaning up..." 11 | cd $base_dir 12 | rm -rf ./work/ 13 | rm -f init-shim/init.go* 14 | } 15 | 16 | if [[ $# -eq 0 ]] ; then 17 | log "must supply docker image as first arg" 18 | exit 1 19 | fi 20 | 21 | base_dir=$(pwd) 22 | require $base_dir 23 | 24 | cleanup 25 | rm -f ./rootfs.ext4 26 | 27 | log "setting up requirements..." 28 | git submodule init 29 | git submodule update --recursive --remote --progress 30 | cd hcsshim # ./hcsshim 31 | go build ./cmd/tar2ext4/ 32 | mv tar2ext4 $base_dir/tar2ext4 33 | cd $base_dir # . 34 | 35 | docker_image=$1 36 | require $docker_image 37 | 38 | rm -rf ./work 39 | mkdir -p ./work 40 | 41 | log "fetching docker image as needed..." 42 | # Check if image exists 43 | img=$(docker image ls | grep "$docker_image" || true) 44 | if [[ $img == "" ]]; then 45 | log "image not found, fetching..." 46 | docker pull $docker_image 47 | fi 48 | 49 | log "dumping docker image..." 50 | docker save $docker_image > work/image.tar 51 | 52 | cd work # ./work 53 | tar -xf image.tar 54 | 55 | log "extracting rootfs layers..." 56 | 57 | # Config file contains cmd + env 58 | config_file=$(cat manifest.json | jq -r '.[].Config') 59 | 60 | command=$(cat $config_file | jq -r '.config.Cmd[0] | @sh' | tr -d \' | sed -e 's/ *$//g') 61 | command_args=$(cat $config_file | jq -r 'del(.config.Cmd[0]).config.Cmd' | sed -e 's/^\[//' -e 's/^]//' | tr '\n' ' ' | sed -e 's/&/\\&/g' -e 's/ *$//g') 62 | 63 | entrypoint=$(cat $config_file | jq -r '.config.Entrypoint[0] | @sh' | tr -d \' | sed -e 's/ *$//g') 64 | entrypoint_args=$(cat $config_file | jq -r 'del(.config.Entrypoint[0]).config.Entrypoint' | sed -e 's/^\[//' -e 's/^]//' | tr '\n' ' ' | sed -e 's/ *$//g' -e 's/&/\\&/g') 65 | 66 | if [ "$entrypoint" == "null" ]; then 67 | entrypoint="" 68 | fi 69 | if [ "$entrypoint_args" == "null" ]; then 70 | entrypoint_args="" 71 | fi 72 | if [ "$command" == "null" ]; then 73 | command="" 74 | fi 75 | if [ "$command_args" == "null" ]; then 76 | command_args="" 77 | fi 78 | 79 | env=($(cat $config_file | jq -r '.config.Env' | sed -e 's/^\[//' -e 's/^]//' | tr '\n' ' ')) 80 | 81 | # Manifest contains layers 82 | layers=$(cat manifest.json | jq -r '.[].Layers | @sh' | tr -d \') 83 | 84 | # Extract rootfs layers in order to build the final rootfs 85 | mkdir -p ./rootfs 86 | for layer in $layers; do 87 | log "extracting layer: $layer..." 88 | tar -xf $layer -C ./rootfs 89 | done 90 | 91 | log "setting up /sbin/init..." 92 | cd .. # . 93 | 94 | # Prepare the template 95 | cp init-shim/init-tpl.go ./init-shim/init.go 96 | 97 | # Template processing 98 | sed -i -e "s|%EXE%|${command}|" ./init-shim/init.go 99 | sed -i -e "s|\"%ENV%\"|${env}|" ./init-shim/init.go 100 | sed -i -e "s|\"%ARGS%\"|${command_args}|" ./init-shim/init.go 101 | sed -i -e "s|%ENTRYPOINT%|${entrypoint:-}|" ./init-shim/init.go 102 | sed -i -e "s|\"%ENTRYPOINT_ARGS%\"|${entrypoint_args:-}|" ./init-shim/init.go 103 | 104 | # log "generated init:" 105 | # cat ./init-shim/init.go 106 | 107 | # Build minimal init 108 | cd ./init-shim # ./init-shim 109 | go mod tidy -v 110 | log "okay that maybe worked, let's try building..." 111 | # We disable CGO here because it breaks on Alpine 112 | env CGO_ENABLED=0 go build ./init.go 113 | cd .. # . 114 | 115 | log "relocating init to /sbin/init..." 116 | cd ./work/ # ./work 117 | mkdir -pv rootfs/{sbin,dev,proc,run,sys,var} 118 | rm -f rootfs/sbin/init 119 | mv $base_dir/init-shim/init rootfs/sbin/init 120 | 121 | log "building final rootfs..." 122 | 123 | # tar it up and turn it into an ext4 image 124 | cd rootfs # ./work/rootfs 125 | tar -cf ../rootfs.tar * 126 | 127 | log "building ext4 image..." 128 | cd .. # ./work 129 | $base_dir/tar2ext4 -i ./rootfs.tar -o $base_dir/rootfs.ext4 130 | 131 | # Remove the ro attr that tar2ext4 sets 132 | tune2fs -O ^read-only $base_dir/rootfs.ext4 133 | 134 | log "growing rootfs..." 135 | # Grow the rootfs to 1GB 136 | dd if=/dev/zero bs=1G seek=1 count=0 of=$base_dir/rootfs.ext4 137 | resize2fs $base_dir/rootfs.ext4 1G 138 | cd $base_dir # . 139 | 140 | log "fetching kernel..." 141 | [ -e kernel.bin ] || wget https://s3.amazonaws.com/spec.ccfc.min/img/hello/kernel/hello-vmlinux.bin -O kernel.bin 142 | 143 | cleanup 144 | 145 | log "all done!" 146 | log "run with: ./boot" 147 | -------------------------------------------------------------------------------- /lib.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | function log() { 4 | echo "[$(env TZ=UTC date +%Y-%m-%dT%H:%M:%S%z)] $*" 5 | } 6 | 7 | function require() { 8 | if [ -z "${1}" ]; then 9 | if [ -z "$2" ]; then 10 | log "\$$1 is not set but must be set." 11 | else 12 | log "\$$2 is not set but must be set." 13 | fi 14 | exit 1 15 | fi 16 | } 17 | -------------------------------------------------------------------------------- /setup-network: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source ./lib.sh 4 | 5 | cidr_range="172.22.0.0/16" 6 | 7 | log "setting up networking..." 8 | log "note: land uses the ${cidr_range} cidr range" 9 | log "this process uses sudo! be aware!!!" 10 | 11 | log "but first, what's your primary network interface?" 12 | read -p "interface: " network_interface 13 | 14 | log "setting up bridge network..." 15 | # Set up the bridge network 16 | sudo ip link add name br0 type bridge 17 | # Tie the bridge to our CIDR range 18 | sudo ip addr add "${cidr_range}" dev br0 19 | sudo ip link set br0 up 20 | 21 | log "allowing ip forwarding..." 22 | sudo sysctl -w net.ipv4.ip_forward=1 23 | 24 | log "setting up iptables rules..." 25 | # NAT the VM 26 | sudo iptables --table nat --append POSTROUTING --out-interface $network_interface -j MASQUERADE 27 | sudo iptables --insert FORWARD --in-interface br0 -j ACCEPT 28 | 29 | log "building tap device..." 30 | 31 | # Set up tap device to route vm traffic through 32 | sudo ip tuntap add tap0 mode tap 33 | sudo ip addr add "${cidr_range}" tap0 34 | 35 | # Strap the tap device to the bridge 36 | sudo brctl addif br0 tap0 37 | sudo ip link set tap0 up 38 | 39 | log "Done!" -------------------------------------------------------------------------------- /vm_config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "boot-source": { 3 | "kernel_image_path": "./kernel.bin", 4 | "boot_args": "console=ttyS0 reboot=k panic=1 pci=off", 5 | "initrd_path": null 6 | }, 7 | "drives": [ 8 | { 9 | "drive_id": "rootfs", 10 | "path_on_host": "rootfs.ext4", 11 | "is_root_device": true, 12 | "is_read_only": false 13 | } 14 | ], 15 | "machine-config": { 16 | "vcpu_count": 1, 17 | "mem_size_mib": 512, 18 | "smt": false, 19 | "track_dirty_pages": false 20 | }, 21 | "network-interfaces": { 22 | "iface_id": "eth0", 23 | "host_dev_name": "tap0" 24 | } 25 | } 26 | --------------------------------------------------------------------------------