Added multiple OS choices
This commit is contained in:
parent
1ea5535f2e
commit
04ee6698df
2 changed files with 202 additions and 115 deletions
2
build.sh
Executable file
2
build.sh
Executable file
|
|
@ -0,0 +1,2 @@
|
||||||
|
#!/bin/sh
|
||||||
|
go build -o ./microvm
|
||||||
289
main.go
289
main.go
|
|
@ -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 {
|
func ensureBaseImage(osName string) {
|
||||||
if data, err := os.ReadFile(k); err == nil {
|
img := osImages[osName]
|
||||||
return strings.TrimSpace(string(data))
|
path := imagePath(osName)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
panic("no SSH public key found in ~/.ssh")
|
if _, err := os.Stat(path); err == nil {
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue