diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..9b60e36 --- /dev/null +++ b/build.sh @@ -0,0 +1,2 @@ +#!/bin/sh +go build -o ./microvm diff --git a/main.go b/main.go index 23012f2..3cfe2cd 100644 --- a/main.go +++ b/main.go @@ -16,13 +16,44 @@ const ( imageDir = rootDir + "/images" vmDirBase = rootDir + "/vms" - defaultImgURL = "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img" - defaultImgName = "ubuntu-24.04-cloudinit.qcow2" - memMB = "1024" cpus = "1" ) +type OSImage struct { + Name string + URL string + Filename string + User string +} + +var osImages = map[string]OSImage{ + "ubuntu": { + Name: "ubuntu", + URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img", + Filename: "ubuntu-24.04.qcow2", + User: "ubuntu", + }, + "debian": { + Name: "debian", + URL: "https://cloud.debian.org/images/cloud/trixie/20251117-2299/debian-13-generic-amd64-20251117-2299.qcow2", + Filename: "debian-13.qcow2", + User: "debian", + }, + "fedora": { + Name: "fedora", + URL: "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-43-1.6.x86_64.qcow2", + Filename: "fedora-43.qcow2", + User: "fedora", + }, + "alpine": { + Name: "alpine", + URL: "https://dl-cdn.alpinelinux.org/alpine/v3.22/releases/cloud/generic_alpine-3.22.2-x86_64-uefi-cloudinit-r0.qcow2", + Filename: "alpine-3.22.qcow2", + User: "alpine", + }, +} + func ensureDirs() { for _, d := range []string{imageDir, vmDirBase} { if err := os.MkdirAll(d, 0755); err != nil { @@ -31,19 +62,65 @@ func ensureDirs() { } } -func baseImagePath() string { - return filepath.Join(imageDir, defaultImgName) +func usage() { + fmt.Println("usage:") + fmt.Println(" microvm create --os --pubkey-path ") + fmt.Println(" microvm start ") + fmt.Println(" microvm stop ") + fmt.Println(" microvm delete ") + fmt.Println(" microvm list") + os.Exit(1) } -func vmDir(name string) string { return filepath.Join(vmDirBase, name) } -func pidFile(name string) string { return filepath.Join(vmDir(name), "vm.pid") } -func diskPath(name string) string { - return filepath.Join(vmDir(name), "disk.qcow2") +func vmDir(name string) string { return filepath.Join(vmDirBase, name) } +func pidFile(name string) string { return filepath.Join(vmDir(name), "vm.pid") } +func diskPath(name string) string { return filepath.Join(vmDir(name), "disk.qcow2") } +func seedISOPath(name string) string { return filepath.Join(vmDir(name), "seed.iso") } +func vmPubKeyPath(name string) string { return filepath.Join(vmDir(name), "pubkey.pub") } + +func imagePath(osName string) string { + return filepath.Join(imageDir, osImages[osName].Filename) } -func seedISOPath(name string) string { - return filepath.Join(vmDir(name), "seed.iso") + +func deleteVM(name string) { + dir := vmDir(name) + + if _, err := os.Stat(dir); err != nil { + panic("VM does not exist: " + name) + } + + // If running, stop it first + if data, err := os.ReadFile(pidFile(name)); err == nil { + pid := strings.TrimSpace(string(data)) + if _, err := os.Stat("/proc/" + pid); err == nil { + fmt.Println("VM is running, stopping it first...") + exec.Command("kill", pid).Run() + } + os.Remove(pidFile(name)) + } + + fmt.Printf(` +WARNING: This will permanently delete the VM "%s" +Path: %s + +Type YES (all caps) to confirm: `, name, dir) + + var confirm string + fmt.Scanln(&confirm) + + if confirm != "YES" { + fmt.Println("Aborted. VM not deleted.") + return + } + + if err := os.RemoveAll(dir); err != nil { + panic(err) + } + + fmt.Println("VM deleted:", name) } + func sshPort(name string) int { if strings.HasPrefix(name, "vm") { if n, err := strconv.Atoi(strings.TrimPrefix(name, "vm")); err == nil { @@ -53,86 +130,44 @@ func sshPort(name string) int { return 2222 } -func readSSHPublicKey() string { - home, err := os.UserHomeDir() - if err != nil { - panic(err) - } - - keys := []string{ - filepath.Join(home, ".ssh", "id_ed25519.pub"), - filepath.Join(home, ".ssh", "id_rsa.pub"), - } - - for _, k := range keys { - if data, err := os.ReadFile(k); err == nil { - return strings.TrimSpace(string(data)) +func parseArg(args []string, key string) string { + for i := 0; i < len(args)-1; i++ { + if args[i] == key { + return args[i+1] } } - - panic("no SSH public key found in ~/.ssh") + panic("missing required argument: " + key) } -func createCloudInitSeed(name string) { - sshKey := readSSHPublicKey() - - userData := fmt.Sprintf(`#cloud-config -users: - - name: ubuntu - sudo: ALL=(ALL) NOPASSWD:ALL - shell: /bin/bash - ssh_authorized_keys: - - %s - -ssh_pwauth: false -disable_root: true -`, sshKey) - - metaData := fmt.Sprintf(`instance-id: %s -local-hostname: %s -`, name, name) - - tmpDir, err := os.MkdirTemp("", "cloudinit") +func readPublicKey(path string) string { + data, err := os.ReadFile(path) if err != nil { panic(err) } - defer os.RemoveAll(tmpDir) - - userDataPath := filepath.Join(tmpDir, "user-data") - metaDataPath := filepath.Join(tmpDir, "meta-data") - - os.WriteFile(userDataPath, []byte(userData), 0644) - os.WriteFile(metaDataPath, []byte(metaData), 0644) - - cmd := exec.Command( - "genisoimage", - "-output", seedISOPath(name), - "-volid", "cidata", - "-joliet", - "-rock", - userDataPath, - metaDataPath, - ) - - if out, err := cmd.CombinedOutput(); err != nil { - panic("cloud-init ISO creation failed:\n" + string(out)) + key := strings.TrimSpace(string(data)) + if !strings.HasPrefix(key, "ssh-") { + panic("invalid SSH public key") } + return key } -func ensureBaseImage() { - if _, err := os.Stat(baseImagePath()); err == nil { +func ensureBaseImage(osName string) { + img := osImages[osName] + path := imagePath(osName) + + if _, err := os.Stat(path); err == nil { return } - fmt.Println("Downloading Ubuntu 24.04 cloud image...") + fmt.Println("Downloading", img.Name, "cloud image...") - resp, err := http.Get(defaultImgURL) + resp, err := http.Get(img.URL) if err != nil { panic(err) } defer resp.Body.Close() - out, err := os.Create(baseImagePath()) + out, err := os.Create(path) if err != nil { panic(err) } @@ -143,15 +178,64 @@ func ensureBaseImage() { } } -func createVM(name string) { - ensureBaseImage() +func createCloudInitSeed(name, osName, pubKeyPath string) { + img := osImages[osName] + sshKey := readPublicKey(pubKeyPath) + + userData := fmt.Sprintf(`#cloud-config +users: + - name: %s + sudo: ALL=(ALL) NOPASSWD:ALL + shell: /bin/sh + ssh_authorized_keys: + - %s + +ssh_pwauth: false +disable_root: true +`, img.User, sshKey) + + metaData := fmt.Sprintf(`instance-id: %s +local-hostname: %s +`, name, name) + + tmpDir, _ := os.MkdirTemp("", "cloudinit") + defer os.RemoveAll(tmpDir) + + os.WriteFile(filepath.Join(tmpDir, "user-data"), []byte(userData), 0644) + os.WriteFile(filepath.Join(tmpDir, "meta-data"), []byte(metaData), 0644) + + cmd := exec.Command( + "genisoimage", + "-output", seedISOPath(name), + "-volid", "cidata", + "-joliet", + "-rock", + filepath.Join(tmpDir, "user-data"), + filepath.Join(tmpDir, "meta-data"), + ) + + if out, err := cmd.CombinedOutput(); err != nil { + panic(string(out)) + } +} + +func createVM(name, osName, pubKeyPath string) { + if _, ok := osImages[osName]; !ok { + panic("unsupported OS: " + osName) + } + + ensureBaseImage(osName) os.MkdirAll(vmDir(name), 0755) + keyDst := vmPubKeyPath(name) + data, _ := os.ReadFile(pubKeyPath) + os.WriteFile(keyDst, data, 0644) + cmd := exec.Command( "qemu-img", "create", "-f", "qcow2", "-F", "qcow2", - "-b", baseImagePath(), + "-b", imagePath(osName), diskPath(name), ) @@ -159,15 +243,14 @@ func createVM(name string) { panic(string(out)) } - createCloudInitSeed(name) + createCloudInitSeed(name, osName, keyDst) - fmt.Println("VM created:", name) + fmt.Println("VM created:", name, "OS:", osName) } func startVM(name string) { if data, err := os.ReadFile(pidFile(name)); err == nil { - pid := strings.TrimSpace(string(data)) - if _, err := os.Stat("/proc/" + pid); err == nil { + if _, err := os.Stat("/proc/" + strings.TrimSpace(string(data))); err == nil { panic("VM already running") } os.Remove(pidFile(name)) @@ -176,51 +259,39 @@ func startVM(name string) { port := sshPort(name) cmd := exec.Command( - "qemu-system-x86_64", - "-enable-kvm", - "-machine", "q35", - "-cpu", "host", - "-m", memMB, - "-smp", cpus, + "qemu-system-x86_64", + "-enable-kvm", + "-machine", "q35", + "-cpu", "host", + "-m", memMB, + "-smp", cpus, - // VNC-only console - "-vga", "virtio", - "-display", "vnc=0.0.0.0:1", + "-vga", "virtio", + "-display", "vnc=0.0.0.0:1", - // Main disk (cloud image) - "-drive", fmt.Sprintf("file=%s,if=virtio,format=qcow2", diskPath(name)), + "-drive", fmt.Sprintf("file=%s,if=virtio,format=qcow2", diskPath(name)), + "-drive", fmt.Sprintf("file=%s,if=virtio,format=raw", seedISOPath(name)), - // Cloud-init seed ISO - "-drive", fmt.Sprintf("file=%s,if=virtio,format=raw", seedISOPath(name)), + "-netdev", fmt.Sprintf("user,id=net0,hostfwd=tcp::%d-:22", port), + "-device", "virtio-net-pci,netdev=net0", - // User-mode networking + SSH forward - "-netdev", fmt.Sprintf("user,id=net0,hostfwd=tcp::%d-:22", port), - "-device", "virtio-net-pci,netdev=net0", - - // Boot from disk (GRUB inside image) - "-boot", "order=c", - - "-pidfile", pidFile(name), - "-daemonize", + "-boot", "order=c", + "-pidfile", pidFile(name), + "-daemonize", ) - if out, err := cmd.CombinedOutput(); err != nil { - panic("qemu failed:\n" + string(out)) + panic(string(out)) } fmt.Println("VM started:", name) - fmt.Printf("VNC: localhost:5901\n") - fmt.Printf("SSH: ssh ubuntu@localhost -p %d\n", port) + fmt.Println("VNC: localhost:5901") + fmt.Printf("SSH: ssh @localhost -p %d\n", port) } func stopVM(name string) { - data, err := os.ReadFile(pidFile(name)) - if err != nil { - panic("VM not running") - } - pid := strings.TrimSpace(string(data)) - exec.Command("kill", pid).Run() + data, _ := os.ReadFile(pidFile(name)) + exec.Command("kill", strings.TrimSpace(string(data))).Run() os.Remove(pidFile(name)) fmt.Println("VM stopped:", name) } @@ -234,23 +305,37 @@ func listVMs() { func main() { if len(os.Args) < 2 { - fmt.Println("usage: microvm ") - os.Exit(1) + usage() } ensureDirs() switch os.Args[1] { case "create": - createVM(os.Args[2]) + if len(os.Args) < 3 { + usage() + } + osName := parseArg(os.Args, "--os") + pubKey := parseArg(os.Args, "--pubkey-path") + createVM(os.Args[2], osName, pubKey) + case "start": startVM(os.Args[2]) + case "stop": stopVM(os.Args[2]) + + case "delete": + if len(os.Args) < 3 { + usage() + } + deleteVM(os.Args[2]) + case "list": listVMs() + default: - fmt.Println("usage: microvm ") + usage() } }