woodpecker-in-a-vm/index.js

195 lines
4.6 KiB
JavaScript

import { nanoid } from "nanoid";
import { execFile as execFileCallback } from "node:child_process";
import {
constants,
copyFile,
mkdir,
opendir,
readFile,
readdir,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { promisify } from "node:util";
import { init } from "@nulo/apkit";
import * as dotenv from "dotenv";
dotenv.config({ override: true });
const execFile = promisify(execFileCallback);
// TODO: configurar runsc
const currRootPath = await setupRootfsDir();
const alpine = await init(currRootPath);
await alpine.install(["alpine-base", "fish", "docker", "linux-virt"]);
await alpine.rcUpdate("sysinit", "networking");
await alpine.rcUpdate("boot", "docker");
await alpine.rcUpdate("boot", "mdev");
await alpine.rcUpdate("default", "ntpd");
await alpine.rcUpdate("default", "local");
/**
* @param {string} path
* @param {((file: string) => string) | ((file: string) => Promise<string>)} patchFunc
* @returns {Promise<void>}
*/
async function patchFile(path, patchFunc) {
let file = await readFile(path, "utf-8");
file = await patchFunc(file);
await writeFile(path, file);
}
await patchFile(
join(currRootPath, "/etc/modprobe.d/blacklist.conf"),
(blacklist) => blacklist.replace("tiny_power_button", "button")
);
await patchFile(
join(currRootPath, "/etc/rc.conf"),
(rc) => rc + '\nrc_cgroup_mode="unified"\n'
);
await writeFile(
"interfaces",
`auto lo
iface lo inet loopback
auto eth0
iface eth0
use dhcp`,
{ mode: 0o600 }
);
await execFile("sudo", [
"mv",
"interfaces",
join(currRootPath, "/etc/network/interfaces"),
]);
await execFile("sudo", [
"chown",
"0:0",
join(currRootPath, "/etc/network/interfaces"),
]);
const woodpeckerServer = getConfig("WOODPECKER_SERVER");
const woodpeckerAgentSecret = getConfig("WOODPECKER_AGENT_SECRET");
await writeFile(
"woodpecker.start",
`#!/bin/sh
modprobe tiny-power-button
while ! docker pull docker.io/woodpeckerci/woodpecker-agent:latest; do
sleep 1
done
exec docker run --detach --rm \
--name=woodpecker-agent \
-v /var/run/docker.sock:/var/run/docker.sock \
-e WOODPECKER_SERVER='${woodpeckerServer}' \
-e WOODPECKER_AGENT_SECRET='${woodpeckerAgentSecret}' \
-e WOODPECKER_MAX_PROCS=8 \
docker.io/woodpeckerci/woodpecker-agent:latest \
agent`,
{ mode: 0o700 }
);
await execFile("sudo", [
"mv",
"woodpecker.start",
join(currRootPath, "/etc/local.d/woodpecker.start"),
]);
await execFile("sudo", [
"chown",
"0:0",
join(currRootPath, "/etc/local.d/woodpecker.start"),
]);
await alpine.rcUpdate("boot", "localmount");
await writeFstab(currRootPath);
// make VM disk image
const ext4Path = "raw.ext4";
await execFile("qemu-img", ["create", "-f", "raw", ext4Path, "50G"]);
await execFile("mkfs.ext4", [ext4Path]);
const mntdir = join(tmpdir(), "woodpecker-in-a-vm-" + nanoid());
await mkdir(mntdir);
await execFile("sudo", ["mount", ext4Path, mntdir]);
for (const path of await readdir(currRootPath))
await execFile("sudo", ["cp", "-r", join(currRootPath, path), mntdir]);
await execFile("sudo", ["umount", mntdir]);
await execFile("sudo", [
"qemu-img",
"convert",
"-O",
"qcow2",
ext4Path,
"vm.qcow2",
]);
await copyFile(
join(currRootPath, "boot/vmlinuz-virt"),
"./vmlinuz-virt",
constants.COPYFILE_FICLONE
);
await copyFile(
join(currRootPath, "boot/initramfs-virt"),
"./initramfs-virt",
constants.COPYFILE_FICLONE
);
await execFile("sudo", [
"chown",
"1000:1000",
"./vmlinuz-virt",
"./initramfs-virt",
"./vm.qcow2",
]);
async function setupRootfsDir() {
const artifactsPath = "./artifacts";
const currRootPath = join(artifactsPath, "rootfs" + nanoid());
await mkdir(currRootPath, { recursive: true });
{
const currentPath = join(artifactsPath, "current");
try {
await rm(currentPath);
} catch {}
await symlink(basename(currRootPath), currentPath);
}
return "./" + currRootPath;
}
/**
* @param {string} rootfs
*/
async function writeFstab(rootfs) {
const rootfsName = "/dev/vda";
const virtualDiskName = "/dev/vda";
await writeFile(
"fstab",
`${rootfsName} / squashfs rw,relatime,discard 0 1
`
);
await execFile("sudo", ["mv", "fstab", join(rootfs, "/etc/fstab")]);
}
/**
* Corre comando en chroot
* @param {string} rootfs
* @param {string[]} cmd
*/
async function chroot(rootfs, cmd) {
await execFile("sudo", ["chroot", rootfs, ...cmd]);
}
/**
* @param {string} name
* @returns {string} la variable conseguida
*/
function getConfig(name) {
if (name in process.env) return process.env[name] || "";
else throw new Error(`Falta la variable ${name}`);
}