Files
jagacloud/pkg/compute/libvirt/virsh_client.go
2025-11-23 13:37:10 +07:00

407 lines
11 KiB
Go

package libvirt
import (
"bytes"
"fmt"
"io"
"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(byte(len(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)
}