Added multiple OS choices

This commit is contained in:
markmental 2025-12-15 13:59:20 -05:00
commit 04ee6698df
2 changed files with 202 additions and 115 deletions

2
build.sh Executable file
View file

@ -0,0 +1,2 @@
#!/bin/sh
go build -o ./microvm

287
main.go
View file

@ -16,13 +16,44 @@ const (
imageDir = rootDir + "/images" imageDir = rootDir + "/images"
vmDirBase = rootDir + "/vms" vmDirBase = rootDir + "/vms"
defaultImgURL = "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img"
defaultImgName = "ubuntu-24.04-cloudinit.qcow2"
memMB = "1024" memMB = "1024"
cpus = "1" 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() { func ensureDirs() {
for _, d := range []string{imageDir, vmDirBase} { for _, d := range []string{imageDir, vmDirBase} {
if err := os.MkdirAll(d, 0755); err != nil { if err := os.MkdirAll(d, 0755); err != nil {
@ -31,19 +62,65 @@ func ensureDirs() {
} }
} }
func baseImagePath() string { func usage() {
return filepath.Join(imageDir, defaultImgName) fmt.Println("usage:")
fmt.Println(" microvm create <name> --os <ubuntu|debian|fedora|alpine> --pubkey-path <path>")
fmt.Println(" microvm start <name>")
fmt.Println(" microvm stop <name>")
fmt.Println(" microvm delete <name>")
fmt.Println(" microvm list")
os.Exit(1)
} }
func vmDir(name string) string { return filepath.Join(vmDirBase, name) } func vmDir(name string) string { return filepath.Join(vmDirBase, name) }
func pidFile(name string) string { return filepath.Join(vmDir(name), "vm.pid") } func pidFile(name string) string { return filepath.Join(vmDir(name), "vm.pid") }
func diskPath(name string) string { func diskPath(name string) string { return filepath.Join(vmDir(name), "disk.qcow2") }
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 { func sshPort(name string) int {
if strings.HasPrefix(name, "vm") { if strings.HasPrefix(name, "vm") {
if n, err := strconv.Atoi(strings.TrimPrefix(name, "vm")); err == nil { if n, err := strconv.Atoi(strings.TrimPrefix(name, "vm")); err == nil {
@ -53,86 +130,44 @@ func sshPort(name string) int {
return 2222 return 2222
} }
func readSSHPublicKey() string { func parseArg(args []string, key string) string {
home, err := os.UserHomeDir() for i := 0; i < len(args)-1; i++ {
if args[i] == key {
return args[i+1]
}
}
panic("missing required argument: " + key)
}
func readPublicKey(path string) string {
data, err := os.ReadFile(path)
if err != nil { if err != nil {
panic(err) panic(err)
} }
key := strings.TrimSpace(string(data))
keys := []string{ if !strings.HasPrefix(key, "ssh-") {
filepath.Join(home, ".ssh", "id_ed25519.pub"), panic("invalid SSH public key")
filepath.Join(home, ".ssh", "id_rsa.pub"),
} }
return key
for _, k := range keys {
if data, err := os.ReadFile(k); err == nil {
return strings.TrimSpace(string(data))
}
}
panic("no SSH public key found in ~/.ssh")
} }
func createCloudInitSeed(name string) { func ensureBaseImage(osName string) {
sshKey := readSSHPublicKey() img := osImages[osName]
path := imagePath(osName)
userData := fmt.Sprintf(`#cloud-config if _, err := os.Stat(path); err == nil {
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")
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))
}
}
func ensureBaseImage() {
if _, err := os.Stat(baseImagePath()); err == nil {
return 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 { if err != nil {
panic(err) panic(err)
} }
defer resp.Body.Close() defer resp.Body.Close()
out, err := os.Create(baseImagePath()) out, err := os.Create(path)
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -143,15 +178,64 @@ func ensureBaseImage() {
} }
} }
func createVM(name string) { func createCloudInitSeed(name, osName, pubKeyPath string) {
ensureBaseImage() 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) os.MkdirAll(vmDir(name), 0755)
keyDst := vmPubKeyPath(name)
data, _ := os.ReadFile(pubKeyPath)
os.WriteFile(keyDst, data, 0644)
cmd := exec.Command( cmd := exec.Command(
"qemu-img", "create", "qemu-img", "create",
"-f", "qcow2", "-f", "qcow2",
"-F", "qcow2", "-F", "qcow2",
"-b", baseImagePath(), "-b", imagePath(osName),
diskPath(name), diskPath(name),
) )
@ -159,15 +243,14 @@ func createVM(name string) {
panic(string(out)) 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) { func startVM(name string) {
if data, err := os.ReadFile(pidFile(name)); err == nil { if data, err := os.ReadFile(pidFile(name)); err == nil {
pid := strings.TrimSpace(string(data)) if _, err := os.Stat("/proc/" + strings.TrimSpace(string(data))); err == nil {
if _, err := os.Stat("/proc/" + pid); err == nil {
panic("VM already running") panic("VM already running")
} }
os.Remove(pidFile(name)) os.Remove(pidFile(name))
@ -183,44 +266,32 @@ func startVM(name string) {
"-m", memMB, "-m", memMB,
"-smp", cpus, "-smp", cpus,
// VNC-only console
"-vga", "virtio", "-vga", "virtio",
"-display", "vnc=0.0.0.0:1", "-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)),
// Cloud-init seed ISO
"-drive", fmt.Sprintf("file=%s,if=virtio,format=raw", seedISOPath(name)), "-drive", fmt.Sprintf("file=%s,if=virtio,format=raw", seedISOPath(name)),
// User-mode networking + SSH forward
"-netdev", fmt.Sprintf("user,id=net0,hostfwd=tcp::%d-:22", port), "-netdev", fmt.Sprintf("user,id=net0,hostfwd=tcp::%d-:22", port),
"-device", "virtio-net-pci,netdev=net0", "-device", "virtio-net-pci,netdev=net0",
// Boot from disk (GRUB inside image)
"-boot", "order=c", "-boot", "order=c",
"-pidfile", pidFile(name), "-pidfile", pidFile(name),
"-daemonize", "-daemonize",
) )
if out, err := cmd.CombinedOutput(); err != nil { if out, err := cmd.CombinedOutput(); err != nil {
panic("qemu failed:\n" + string(out)) panic(string(out))
} }
fmt.Println("VM started:", name) fmt.Println("VM started:", name)
fmt.Printf("VNC: localhost:5901\n") fmt.Println("VNC: localhost:5901")
fmt.Printf("SSH: ssh ubuntu@localhost -p %d\n", port) fmt.Printf("SSH: ssh <user>@localhost -p %d\n", port)
} }
func stopVM(name string) { func stopVM(name string) {
data, err := os.ReadFile(pidFile(name)) data, _ := os.ReadFile(pidFile(name))
if err != nil { exec.Command("kill", strings.TrimSpace(string(data))).Run()
panic("VM not running")
}
pid := strings.TrimSpace(string(data))
exec.Command("kill", pid).Run()
os.Remove(pidFile(name)) os.Remove(pidFile(name))
fmt.Println("VM stopped:", name) fmt.Println("VM stopped:", name)
} }
@ -234,23 +305,37 @@ func listVMs() {
func main() { func main() {
if len(os.Args) < 2 { if len(os.Args) < 2 {
fmt.Println("usage: microvm <create|start|stop|list> <name>") usage()
os.Exit(1)
} }
ensureDirs() ensureDirs()
switch os.Args[1] { switch os.Args[1] {
case "create": 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": case "start":
startVM(os.Args[2]) startVM(os.Args[2])
case "stop": case "stop":
stopVM(os.Args[2]) stopVM(os.Args[2])
case "delete":
if len(os.Args) < 3 {
usage()
}
deleteVM(os.Args[2])
case "list": case "list":
listVMs() listVMs()
default: default:
fmt.Println("usage: microvm <create|start|stop|list> <name>") usage()
} }
} }