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)} patchFunc * @returns {Promise} */ 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}`); }