package libvirt import ( "bytes" "fmt" "io" "io/fs" "os" "os/exec" "path" "path/filepath" "strings" "text/template" ) // VirshClient shells out to virsh; avoids cgo dependencies. type VirshClient struct { URI string TemplatePath string OutputDir string CloudInitDir string } func NewVirshClient(uri, tmpl, outDir string) *VirshClient { return &VirshClient{URI: uri, TemplatePath: tmpl, OutputDir: outDir, CloudInitDir: "/var/lib/libvirt/cloud-init"} } func (c *VirshClient) ListVMs() ([]VM, error) { cmd := exec.Command("virsh", "--connect", c.URI, "list", "--all", "--name") out, err := cmd.Output() if err != nil { return nil, err } lines := strings.Split(strings.TrimSpace(string(out)), "\n") vms := []VM{} for _, line := range lines { name := strings.TrimSpace(line) if name == "" { continue } status := "unknown" statOut, _ := exec.Command("virsh", "--connect", c.URI, "domstate", name).Output() if len(statOut) > 0 { status = strings.TrimSpace(string(statOut)) } vms = append(vms, VM{ID: name, Name: name, Status: status}) } return vms, nil } func (c *VirshClient) CreateVM(spec VMSpec) (VM, error) { if spec.Name == "" { spec.Name = spec.ID } // ensure disks have paths for i := range spec.Disks { if spec.Disks[i].Path == "" { spec.Disks[i].Path = fmt.Sprintf("/var/lib/libvirt/images/%s-%s.qcow2", spec.Name, spec.Disks[i].Name) } if spec.Disks[i].Bus == "" { spec.Disks[i].Bus = "virtio" } if err := ensureDisk(spec.Disks[i]); err != nil { return VM{}, err } } for i := range spec.NICs { if spec.NICs[i].Model == "" { spec.NICs[i].Model = "virtio" } } if spec.CloudInit != nil && spec.CloudInitISO == "" { iso, err := c.createCloudInit(spec) if err != nil { return VM{}, err } spec.CloudInitISO = iso } xml, err := c.render(spec) if err != nil { return VM{}, err } if err := os.MkdirAll(c.OutputDir, 0o755); err != nil { return VM{}, err } path := filepath.Join(c.OutputDir, fmt.Sprintf("%s.xml", spec.Name)) if err := os.WriteFile(path, []byte(xml), 0o644); err != nil { return VM{}, err } if err := c.run("define", path); err != nil { return VM{}, err } if err := c.run("start", spec.Name); err != nil { return VM{}, err } return VM{ID: spec.Name, Name: spec.Name, Status: "running", CPU: spec.CPU, MemoryMB: spec.MemoryMB}, nil } func (c *VirshClient) StartVM(id string) error { return c.run("start", id) } func (c *VirshClient) StopVM(id string) error { return c.run("shutdown", id) } func (c *VirshClient) RebootVM(id string) error { return c.run("reboot", id) } func (c *VirshClient) DeleteVM(id string) error { // Destroy then undefine _ = c.run("destroy", id) return c.run("undefine", id) } func (c *VirshClient) render(spec VMSpec) (string, error) { tmpl, err := template.ParseFiles(c.TemplatePath) if err != nil { return "", err } buf := bytes.NewBuffer(nil) if err := tmpl.Execute(buf, spec); err != nil { return "", err } return buf.String(), nil } func (c *VirshClient) run(args ...string) error { full := append([]string{"--connect", c.URI}, args...) cmd := exec.Command("virsh", full...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } // ensureDisk ensures a qcow2 file exists with given size. func ensureDisk(d DiskSpec) error { if d.SizeGB == 0 && fileExists(d.Path) { return nil } if d.SizeGB == 0 { return fmt.Errorf("disk %s missing size_gb", d.Name) } if err := os.MkdirAll(path.Dir(d.Path), 0o755); err != nil { return err } if fileExists(d.Path) { return nil } args := []string{"create", "-f", "qcow2"} if d.Prealloc == "metadata" { args = append(args, "-o", "preallocation=metadata") } else if d.Prealloc == "full" { args = append(args, "-o", "preallocation=full") } args = append(args, d.Path, fmt.Sprintf("%dG", d.SizeGB)) cmd := exec.Command("qemu-img", args...) cmd.Stdout = io.Discard cmd.Stderr = os.Stderr return cmd.Run() } func fileExists(p string) bool { _, err := os.Stat(p) return err == nil } // createCloudInit generates a simple cloud-init ISO using cloud-localds if available. func (c *VirshClient) createCloudInit(spec VMSpec) (string, error) { if err := os.MkdirAll(c.CloudInitDir, 0o755); err != nil { return "", err } isoPath := filepath.Join(c.CloudInitDir, fmt.Sprintf("%s-cloudinit.iso", spec.Name)) userData := "#cloud-config\n" if spec.CloudInit != nil { if spec.CloudInit.User != "" { userData += fmt.Sprintf("users:\n - name: %s\n sudo: ALL=(ALL) NOPASSWD:ALL\n shell: /bin/bash\n", spec.CloudInit.User) } if len(spec.CloudInit.SSHKeys) > 0 { userData += "ssh_authorized_keys:\n" for _, k := range spec.CloudInit.SSHKeys { userData += fmt.Sprintf(" - %s\n", k) } } if spec.CloudInit.UserData != "" { userData += spec.CloudInit.UserData + "\n" } } // write tmp user-data tmpUD := filepath.Join(c.CloudInitDir, fmt.Sprintf("%s-user-data", spec.Name)) if err := os.WriteFile(tmpUD, []byte(userData), 0o644); err != nil { return "", err } metaData := fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", spec.Name, spec.Name) tmpMD := filepath.Join(c.CloudInitDir, fmt.Sprintf("%s-meta-data", spec.Name)) if err := os.WriteFile(tmpMD, []byte(metaData), 0o644); err != nil { return "", err } if hasBinary("cloud-localds") { cmd := exec.Command("cloud-localds", isoPath, tmpUD, tmpMD) cmd.Stdout = io.Discard cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return "", err } return isoPath, nil } // Fallback: build ISO via genisoimage/mkisofs args := []string{"-output", isoPath, "-volid", "cidata", "-joliet", "-rock", tmpUD, tmpMD} switch { case hasBinary("genisoimage"): cmd := exec.Command("genisoimage", args...) cmd.Stdout = io.Discard cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return "", err } case hasBinary("mkisofs"): cmd := exec.Command("mkisofs", args...) cmd.Stdout = io.Discard cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return "", err } default: if err := buildIsoPureGo(isoPath, tmpUD, tmpMD); err != nil { return "", fmt.Errorf("no iso tools available: %w", err) } } return isoPath, nil } func hasBinary(name string) bool { _, err := exec.LookPath(name) return err == nil } // buildIsoPureGo writes a minimal ISO with two files using only stdlib (ISO9660 Level 1). // This is deliberately simple and avoids Joliet/RockRidge; suitable for cloud-init seed. func buildIsoPureGo(outPath, userDataPath, metaDataPath string) error { files := []struct { Path string Data []byte }{ {"user-data", nil}, {"meta-data", nil}, } for i := range files { data, err := os.ReadFile([]string{userDataPath, metaDataPath}[i]) if err != nil { return err } files[i].Data = data } // ISO layout constants sectorSize := 2048 writeAt := func(w io.WriterAt, off int64, data []byte) error { _, err := w.WriteAt(data, off) return err } // Create file f, err := os.OpenFile(outPath, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0o644) if err != nil { return err } defer f.Close() // Helper to align to sector align := func(off int64) int64 { if off%int64(sectorSize) == 0 { return off } return off + int64(sectorSize) - off%int64(sectorSize) } // Primary Volume Descriptor at sector 16 type fileEntry struct { name string start int64 size int64 } var entries []fileEntry offset := int64(sectorSize * 17) // start writing files after pvd + root dir sector for _, fdesc := range files { offset = align(offset) if _, err := f.WriteAt(fdesc.Data, offset); err != nil { return err } entries = append(entries, fileEntry{name: fdesc.Path, start: offset / int64(sectorSize), size: int64(len(fdesc.Data))}) offset += int64(len(fdesc.Data)) } // Root directory record at sector 17 rootSector := int64(17) dirRec := buildDirRecord(0, 0, 0, true) // current dir dirRec = append(dirRec, buildDirRecord(0, 0, 0, true)...) // parent (same) for _, e := range entries { dirRec = append(dirRec, buildDirRecord(byteLen(e.name), e.start, e.size, false, e.name)...) } dirRec = padTo(dirRec, sectorSize) if err := writeAt(f, rootSector*int64(sectorSize), dirRec); err != nil { return err } // Primary Volume Descriptor pvd := make([]byte, sectorSize) pvd[0] = 1 // type copy(pvd[1:6], "CD001") // id pvd[6] = 1 // version copy(pvd[40:72], padString("CIDATA", 32)) // volume id // volume space size (little and big endian) volSectors := uint32(100) // arbitrary ample space putBothEndian(pvd[80:88], volSectors) pvd[120] = 1 // volume set size lsb pvd[121] = 0 pvd[124] = 1 // volume seq number lsb pvd[125] = 0 pvd[128] = 0x08 // logical block size 2048 LE pvd[129] = 0x00 pvd[130] = 0x08 // logical block size BE pvd[131] = 0x00 // path table size pathTableSize := uint32(len(dirRec)) putBothEndian(pvd[132:140], pathTableSize) // path table location (little endian) putLE(pvd[140:144], uint32(rootSector+1)) // root directory record rootRec := buildDirRecord(1, rootSector, int64(len(dirRec)), true) copy(pvd[156:], rootRec) copy(pvd[190:], padString("JAGACLOUD", 32)) if err := writeAt(f, 16*int64(sectorSize), pvd); err != nil { return err } // Volume descriptor set terminator vdst := make([]byte, sectorSize) vdst[0] = 255 copy(vdst[1:6], "CD001") vdst[6] = 1 if err := writeAt(f, 17*int64(sectorSize), vdst); err != nil { return err } return nil } func buildDirRecord(nameLen byte, extent int64, size int64, isDir bool, name ...string) []byte { n := "." if len(name) > 0 { n = name[0] } rec := []byte{} rec = append(rec, 0) // length placeholder rec = append(rec, 0) // ext attr rec len ext := make([]byte, 8) putLE(ext, uint32(extent)) rec = append(rec, ext...) sz := make([]byte, 8) putLE(sz, uint32(size)) rec = append(rec, sz...) rec = append(rec, []byte{0, 0, 0, 0, 0, 0, 0}...) // date/time flags := byte(0) if isDir { flags = 2 } rec = append(rec, flags) rec = append(rec, []byte{0, 0, 0}...) // unit size, gap, vol seq LSB rec = append(rec, 0, 0) // vol seq MSB rec = append(rec, byte(len(n))) rec = append(rec, []byte(n)...) if len(rec)%2 != 0 { rec = append(rec, 0) } rec[0] = byte(len(rec)) return rec } func padString(s string, l int) []byte { b := make([]byte, l) copy(b, []byte(s)) return b } func padTo(b []byte, size int) []byte { if len(b)%size == 0 { return b } pad := size - len(b)%size return append(b, make([]byte, pad)...) } func putBothEndian(dst []byte, v uint32) { putLE(dst, v) putBE(dst[4:], v) } func putLE(dst []byte, v uint32) { dst[0] = byte(v) dst[1] = byte(v >> 8) dst[2] = byte(v >> 16) dst[3] = byte(v >> 24) } func putBE(dst []byte, v uint32) { dst[0] = byte(v >> 24) dst[1] = byte(v >> 16) dst[2] = byte(v >> 8) dst[3] = byte(v) }