├── .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 |
--------------------------------------------------------------------------------