bootear imgaenes dentro de VM

This commit is contained in:
Cat /dev/Nulo 2023-06-23 15:55:28 -03:00
parent f480b54259
commit d876b46145
10 changed files with 362 additions and 83 deletions

2
.gitignore vendored
View file

@ -8,3 +8,5 @@ vmlinux.bin
node_modules/
*.log
build.cjs

View file

@ -1,5 +1,5 @@
import { execFile as _execFile } from "node:child_process";
import { access, mkdir } from "node:fs/promises";
import { access, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tar2squashfs } from "./tar2squashfs.js";
import { subtle } from "node:crypto";
@ -10,15 +10,23 @@ const getToken = memoizeDownloader(_getToken);
let squashfsDownloads = new Map<string, Promise<string>>();
const tmpDir = "cache/";
{
const image = "gitea.nulo.in/nulo/zulip-checkin-cyborg";
const tag = "latest";
// {
// const image = "gitea.nulo.in/nulo/zulip-checkin-cyborg";
// const tag = "latest";
await mkdir(tmpDir, { recursive: true });
await downloadContainer(image, tag);
// await downloadImage(image, tag);
// }
export const CONFIG_PATH_IN_IMAGE = "/.fireactions-image-config.json";
export function parseImageRef(ref: string): { image: string; tag: string } {
const [image, tag] = ref.split(":");
if (!image || !tag) throw new Error("Invalid image ref " + ref);
return { image, tag };
}
async function downloadContainer(image: string, tag: string) {
export async function downloadImage(image: string, tag: string) {
await mkdir(tmpDir, { recursive: true });
const manifest = await getContainerManifest(image, tag);
// sanity check
@ -34,8 +42,14 @@ async function downloadContainer(image: string, tag: string) {
console.debug(manifest);
await saveSquashfs(image, manifest);
const config = await jsonBlob(image, manifest.config.digest);
const squashfsFile = await saveSquashfs(
image,
manifest,
JSON.stringify(config, null, 2)
);
return { squashfsFile };
}
// https://stackoverflow.com/a/67600346
@ -51,10 +65,11 @@ async function hashData(data: string, algorithm = "SHA-512"): Promise<string> {
async function saveSquashfs(
image: string,
manifest: Manifest
manifest: Manifest,
configJson: string
): Promise<string> {
const manifestDigest = await hashData(JSON.stringify(manifest));
const key = `${image.replaceAll("/", "%")}#${manifestDigest}`;
const key = `${image.replaceAll("/", "%")}#${manifestDigest}.squashfs`;
let p = squashfsDownloads.get(key);
if (!p) {
@ -68,7 +83,17 @@ async function saveSquashfs(
const res = await getBlob(image, layer.digest);
return res.body!;
});
await tar2squashfs(layerStreams, output);
await tar2squashfs(layerStreams, output, [
{
content: configJson,
headers: {
name: CONFIG_PATH_IN_IMAGE,
uid: 0,
gid: 0,
mode: 0o400,
},
},
]);
}
return output;
})();

View file

@ -1,11 +1,10 @@
import { delay } from "nanodelay";
import { nanoid } from "nanoid";
import { ChildProcess, execFile, spawn } from "node:child_process";
import { writeFile } from "node:fs/promises";
import { createServer, request as _request, IncomingMessage } from "node:http";
import { spawn } from "node:child_process";
import { createServer, request as _request } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { downloadImage, parseImageRef } from "./container-baby.js";
const server = createServer(listener);
@ -17,19 +16,43 @@ async function listener(req, res) {
const url = getUrl(req);
if (url.pathname == "/run") {
if (req.method == "POST") {
await runVm();
const image = url.searchParams.get("image");
if (!image) return res.writeHead(400).end("missing image param");
await runVm(image);
} else res.writeHead(405).end("wrong method");
} else res.writeHead(404).end("not found");
}
async function runVm() {
/**
* @param {string} image - ref to an OCI image
*/
async function runVm(image) {
try {
await FirecrackerInstance.run();
const ref = parseImageRef(image);
const { squashfsFile } = await downloadImage(ref.image, ref.tag);
await FirecrackerInstance.run({
drives: [
{
drive_id: "image",
is_read_only: true,
is_root_device: false,
path_on_host: squashfsFile,
},
],
});
} catch (err) {
console.error(err);
}
}
/**
* @typedef {object} Drive
* @prop {string} drive_id
* @prop {string} path_on_host
* @prop {boolean} is_root_device
* @prop {boolean} is_read_only
*/
class FirecrackerInstance {
proc;
socketPath;
@ -52,8 +75,12 @@ class FirecrackerInstance {
}
);
}
static async run() {
/**
* @param [opts] {{ drives?: Drive[] }}
*/
static async run(opts) {
const self = new FirecrackerInstance();
await delay(15);
await self.request("PUT", "/boot-source", {
kernel_image_path: "../vmlinux.bin",
@ -65,6 +92,11 @@ class FirecrackerInstance {
is_root_device: true,
is_read_only: false,
});
if (opts?.drives) {
for (const drive of opts.drives) {
await self.request("PUT", "/drives/" + drive.drive_id, drive);
}
}
// API requests are handled asynchronously, it is important the configuration is
// set, before `InstanceStart`.

View file

@ -5,7 +5,7 @@
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"start": "esbuild --bundle index.js --platform=node --sourcemap > build.cjs && node --enable-source-maps build.cjs"
},
"keywords": [],
"author": "",

View file

@ -24,10 +24,11 @@ import { extract, pack } from "tar-stream";
* squashfs image that can be mounted inside the VM. This way, we reuse downloaded images
* efficiently.
*
* @param streams {Promise<ReadableStream>[]}
* @param output {string}
* @param {Promise<ReadableStream>[]} streams
* @param {string} output
* @param {{ content: string, headers: import("tar-stream").Headers }[]} extraFiles
*/
export async function tar2squashfs(streams, output) {
export async function tar2squashfs(streams, output, extraFiles) {
const child = spawn(
"mksquashfs",
[
@ -38,12 +39,14 @@ export async function tar2squashfs(streams, output) {
...["-Xcompression-level", "3"],
],
{
stdio: "pipe",
// stdio: "pipe",
stdio: ["pipe", "inherit", "inherit"],
}
);
const p = pack();
p.pipe(child.stdin);
p.on("error", console.error);
for (const streamP of streams) {
const stream = await streamP;
@ -61,6 +64,11 @@ export async function tar2squashfs(streams, output) {
})
);
}
for (const { headers, content } of extraFiles) {
p.entry(headers, content);
}
p.finalize();
await new Promise((resolve, reject) =>

View file

@ -1,13 +1,15 @@
// @ts-check
import { init } from "@nulo/apkit";
import { execFile as _execFile } from "node:child_process";
import {
chmod,
copyFile,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
@ -17,73 +19,69 @@ const execFile = promisify(_execFile);
const root = await mkdtemp(join(tmpdir(), "fireactions-rootfs."));
const alpine = await init(root);
await alpine.install(["dropbear", "util-linux", "dropbear-dbclient", "dhcpcd"]);
await alpine.install(["util-linux", "dhcpcd", "eudev"]);
// gotta go fast
await append(r("/etc/rc.conf"), 'rc_parallel="YES"');
const initPath = await buildInit();
await copyFile(initPath, r("/sbin/fireactions-init"));
await mkdirp(r("/usr/local/sbin"));
console.debug(
await execFile("go", [
"build",
"-tags=netgo",
"-o",
r("/usr/local/sbin/fireactions-agent"),
"./agent",
])
);
// https://github.com/OpenRC/openrc/blob/master/service-script-guide.md
await writeFile(
r("/etc/init.d/fireactions-agent"),
`#!/sbin/openrc-run
pidfile="/run/\${RC_SVCNAME}.pid"
command_background=true
command=/usr/local/sbin/fireactions-agent
output_log=/dev/stdout
error_log=/dev/stdout
await writeSbin(
r("/sbin/init"),
`#!/bin/sh
/etc/init-files/00-pseudofs.sh
/etc/init-files/05-misc.sh
#exec /sbin/fireactions-init
/sbin/fireactions-init || sh
`
);
await chmod(r("/etc/init.d/fireactions-agent"), 0o700);
await alpine.rcUpdate("default", "fireactions-agent");
await alpine.rcUpdate("boot", "devfs");
await alpine.rcUpdate("boot", "procfs");
await alpine.rcUpdate("boot", "sysfs");
// alpine.rcUpdate('default', 'dhcpcd')
await alpine.rcUpdate("default", "dropbear");
await writeFile(r("/etc/securetty"), "ttyS0");
await symlink("agetty", r("/etc/init.d/agetty.ttyS0"));
await alpine.rcUpdate("default", "agetty.ttyS0");
await mkdirp(r("/etc/dropbear"));
for (const t of ["rsa", "dss", "ed25519", "ecdsa"]) {
await execFile("dropbearkey", [
"-t",
t,
"-f",
r(`/etc/dropbear/dropbear_${t}_host_key`),
]);
}
await execFile("sudo", ["mkdir", "-p", r("/root/.ssh")]);
await writeFile(
"authorized_keys",
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEhrcTPMrhrOdwpgFkRFjTt8rdxt3gg16LJvBCZENRWa user@personal"
await mkdir(r("/etc/init-files/"));
await writeSbin(
r("/etc/init-files/00-pseudofs.sh"),
`#!/bin/sh
mountpoint -q /proc || mount -o nosuid,noexec,nodev -t proc proc /proc
mountpoint -q /sys || mount -o nosuid,noexec,nodev -t sysfs sys /sys
mountpoint -q /run || mount -o mode=0755,nosuid,nodev -t tmpfs run /run
mountpoint -q /dev || mount -o mode=0755,nosuid -t devtmpfs dev /dev
mkdir -p -m0755 /run/runit /run/lvm /run/user /run/lock /run/log /dev/pts /dev/shm
mountpoint -q /dev/pts || mount -o mode=0620,gid=5,nosuid,noexec -n -t devpts devpts /dev/pts
mountpoint -q /dev/shm || mount -o mode=1777,nosuid,nodev -n -t tmpfs shm /dev/shm
mountpoint -q /sys/kernel/security || mount -n -t securityfs securityfs /sys/kernel/security
`
);
await execFile("sudo", [
"mv",
"authorized_keys",
r("/root/.ssh/authorized_keys"),
]);
await alpine.rcUpdate("default", "networking");
await writeFile(
r("/etc/network/interfaces"),
`auto lo
iface lo inet loopback`
await writeSbin(
r("/etc/init-files/05-misc.sh"),
`#!/bin/sh
install -m0664 -o root -g utmp /dev/null /run/utmp
#halt -B # for wtmp
ip link set up dev lo
hostname -F /etc/hostname
`
);
// await mkdirp(r("/etc/dropbear"));
// for (const t of ["rsa", "dss", "ed25519", "ecdsa"]) {
// await execFile("dropbearkey", [
// "-t",
// t,
// "-f",
// r(`/etc/dropbear/dropbear_${t}_host_key`),
// ]);
// }
// await execFile("sudo", ["mkdir", "-p", r("/root/.ssh")]);
// await writeFile(
// "authorized_keys",
// "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEhrcTPMrhrOdwpgFkRFjTt8rdxt3gg16LJvBCZENRWa user@personal"
// );
// await execFile("sudo", [
// "mv",
// "authorized_keys",
// r("/root/.ssh/authorized_keys"),
// ]);
const ext4 = "rootfs.ext4";
await rm(ext4);
await execFile("fallocate", ["--length", "1G", ext4]);
@ -129,3 +127,27 @@ async function mkdirp(path) {
function r(path) {
return join(root, path);
}
/**
* @param {import("fs").PathLike} path
* @param {string} content
*/
async function writeSbin(path, content) {
try {
await lstat(path);
await rm(path, { recursive: true });
} catch {}
await writeFile(path, content);
await chmod(path, 0o500);
}
async function buildInit() {
const srcPath = "./rootfs/init";
await execFile("podman", [
...["run", "--rm", "-it"],
...["-v", `${srcPath}:/home/rust/src:Z`],
"docker.io/messense/rust-musl-cross:x86_64-musl",
...["cargo", "build", "--release"],
]);
return join(srcPath, "target/x86_64-unknown-linux-musl/release/init");
}

1
rootfs/init/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
target/

126
rootfs/init/Cargo.lock generated Normal file
View file

@ -0,0 +1,126 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "cc"
version = "1.0.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f"
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "init"
version = "0.1.0"
dependencies = [
"nix",
"serde",
"serde_json",
]
[[package]]
name = "itoa"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6"
[[package]]
name = "libc"
version = "0.2.146"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f92be4933c13fd498862a9e02a3055f8a8d9c039ce33db97306fd5a6caa7f29b"
[[package]]
name = "nix"
version = "0.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2ccba0cfe4fdf15982d1674c69b1fd80bad427d293849982668dfe454bd61f2"
dependencies = [
"bitflags",
"cc",
"cfg-if",
"libc",
]
[[package]]
name = "proc-macro2"
version = "1.0.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec2b086b7a862cf4de201096214fa870344cf922b2b30c167badb3af3195406"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ab9c7eadfd8df19006f1cf1a4aed13540ed5cbc047010ece5826e10825488"
dependencies = [
"proc-macro2",
]
[[package]]
name = "ryu"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041"
[[package]]
name = "serde"
version = "1.0.164"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e8c8cf938e98f769bc164923b06dce91cea1751522f46f8466461af04c9027d"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.164"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9735b638ccc51c28bf6914d90a2e9725b377144fc612c49a611fddd1b631d68"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.97"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdf3bf93142acad5821c99197022e170842cdbc1c30482b98750c688c640842a"
dependencies = [
"itoa",
"ryu",
"serde",
]
[[package]]
name = "syn"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32d41677bcbe24c20c52e7c70b0d8db04134c5d1066bf98662e2871ad200ea3e"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0"

11
rootfs/init/Cargo.toml Normal file
View file

@ -0,0 +1,11 @@
[package]
name = "init"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
serde = { version = "1.0.164", features = ["derive"] }
serde_json = "1.0.97"
nix = "0.19"

52
rootfs/init/src/main.rs Normal file
View file

@ -0,0 +1,52 @@
use nix::mount::MsFlags;
use serde::{Deserialize, Serialize};
use std::fs;
use std::{env, io, os::unix, path::Path, process};
// inspirado en https://github.com/superfly/init-snapshot/blob/public/src/bin/init/main.rs#L230
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct Config {
env: Vec<String>,
cmd: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug)]
struct ImageConfig {
architecture: String,
config: Config,
}
fn main() -> Result<(), io::Error> {
let src_path = "/image_root";
fs::create_dir_all(src_path)?;
nix::mount::mount::<_, _, _, [u8]>(
Some("/dev/vdb"),
src_path,
Some("squashfs"),
MsFlags::MS_RDONLY,
None,
)
.unwrap();
let config_file = Path::new(&src_path).join(".fireactions-image-config.json");
let config_text = fs::read(config_file)?;
let config: ImageConfig = serde_json::from_slice(&config_text).unwrap();
println!("{:#?}", config);
unix::fs::chroot(src_path)?;
env::set_current_dir("/")?;
let mut child = process::Command::new(&config.config.cmd[0])
.args(&config.config.cmd[1..])
.env_clear()
.envs(config.config.env.iter().map(|s| s.split_once('=').unwrap()))
.spawn()?;
let status = child.wait()?;
if !status.success() {
println!("{}", status);
}
Ok(())
}