408 lines
11 KiB
Go
408 lines
11 KiB
Go
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)
|
|
}
|