Pulse/internal/hostagent/libvirt.go
2026-07-30 20:29:04 +01:00

337 lines
8.3 KiB
Go

package hostagent
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"math"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
)
const (
libvirtQueryTimeout = 8 * time.Second
libvirtMaxDomains = 128
libvirtMaxDomainNameBytes = 256
libvirtMaxListOutputBytes = 64 * 1024
libvirtMaxStatsOutputBytes = 1024 * 1024
)
var libvirtStatsArgs = []string{
"--readonly",
"domstats",
"--raw",
"--nowait",
"--state",
"--cpu-total",
"--balloon",
"--vcpu",
"--interface",
"--block",
}
type limitedCommandOutputCollector interface {
CommandCombinedOutputLimited(
ctx context.Context,
maxBytes int,
name string,
arg ...string,
) (string, error)
}
func collectCommandOutputLimited(
ctx context.Context,
collector SystemCollector,
maxBytes int,
name string,
arg ...string,
) (string, error) {
if limited, ok := collector.(limitedCommandOutputCollector); ok {
return limited.CommandCombinedOutputLimited(ctx, maxBytes, name, arg...)
}
output, err := collector.CommandCombinedOutput(ctx, name, arg...)
if len(output) > maxBytes {
return output[:maxBytes], fmt.Errorf("command output exceeds %d bytes", maxBytes)
}
return output, err
}
// collectLibvirtInventory auto-detects a local read-only libvirt connection.
// It is deliberately best-effort: hosts without virsh, socket access, or a
// compatible driver continue reporting normal host telemetry.
func (a *Agent) collectLibvirtInventory(ctx context.Context) *agentshost.LibvirtInventory {
if a.collector.GOOS() != "linux" {
return nil
}
path, err := a.collector.LookPath("virsh")
if err != nil {
if !errors.Is(err, exec.ErrNotFound) && !os.IsNotExist(err) {
a.logger.Debug().Err(err).Msg("Failed to locate virsh for libvirt inventory")
}
return nil
}
queryCtx, cancel := context.WithTimeout(ctx, libvirtQueryTimeout)
defer cancel()
listOutput, err := collectCommandOutputLimited(
queryCtx,
a.collector,
libvirtMaxListOutputBytes,
path,
"--readonly",
"list",
"--all",
"--name",
)
if err != nil {
a.logger.Debug().Err(err).Msg("Failed to list local libvirt domains")
return nil
}
names, err := parseLibvirtDomainList(listOutput)
if err != nil {
a.logger.Debug().Err(err).Msg("Failed to parse local libvirt domain list")
return nil
}
inventory := &agentshost.LibvirtInventory{
Domains: []agentshost.LibvirtDomain{},
CollectedAt: a.collector.Now().UTC(),
}
if len(names) == 0 {
return inventory
}
args := make([]string, 0, len(libvirtStatsArgs)+len(names))
args = append(args, libvirtStatsArgs...)
args = append(args, names...)
statsOutput, err := collectCommandOutputLimited(
queryCtx,
a.collector,
libvirtMaxStatsOutputBytes,
path,
args...,
)
if err != nil {
a.logger.Debug().Err(err).Msg("Failed to collect local libvirt domain stats")
return nil
}
domains, err := parseLibvirtDomainStats(statsOutput, names)
if err != nil {
a.logger.Debug().Err(err).Msg("Failed to parse local libvirt domain stats")
return nil
}
inventory.Domains = domains
return inventory
}
func parseLibvirtDomainList(output string) ([]string, error) {
if len(output) > libvirtMaxListOutputBytes {
return nil, fmt.Errorf("libvirt domain list exceeds %d bytes", libvirtMaxListOutputBytes)
}
names := make([]string, 0)
seen := make(map[string]struct{})
for _, raw := range strings.Split(strings.TrimSpace(output), "\n") {
name := strings.TrimSpace(strings.TrimSuffix(raw, "\r"))
if !validLibvirtDomainName(name) {
continue
}
key := strings.ToLower(name)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
names = append(names, name)
if len(names) == libvirtMaxDomains {
break
}
}
sort.Slice(names, func(i, j int) bool {
return strings.ToLower(names[i]) < strings.ToLower(names[j])
})
return names, nil
}
func validLibvirtDomainName(name string) bool {
if name == "" ||
len(name) > libvirtMaxDomainNameBytes ||
!utf8.ValidString(name) ||
strings.HasPrefix(name, "-") {
return false
}
for _, r := range name {
if r < 0x20 || r == 0x7f {
return false
}
}
return true
}
func parseLibvirtDomainStats(output string, expectedNames []string) ([]agentshost.LibvirtDomain, error) {
if len(output) > libvirtMaxStatsOutputBytes {
return nil, fmt.Errorf("libvirt domain stats exceed %d bytes", libvirtMaxStatsOutputBytes)
}
expected := make(map[string]string, len(expectedNames))
for _, name := range expectedNames {
if validLibvirtDomainName(name) {
expected[strings.ToLower(name)] = name
}
}
records := make(map[string]*agentshost.LibvirtDomain, len(expected))
var current *agentshost.LibvirtDomain
for _, rawLine := range strings.Split(output, "\n") {
line := strings.TrimSpace(strings.TrimSuffix(rawLine, "\r"))
if strings.HasPrefix(line, "Domain: '") && strings.HasSuffix(line, "'") {
name := strings.TrimSuffix(strings.TrimPrefix(line, "Domain: '"), "'")
canonical, ok := expected[strings.ToLower(name)]
if !ok {
current = nil
continue
}
key := strings.ToLower(canonical)
if existing := records[key]; existing != nil {
current = existing
continue
}
domain := agentshost.LibvirtDomain{
ID: libvirtDomainID(canonical),
Name: canonical,
State: "unknown",
}
records[key] = &domain
current = &domain
continue
}
if current == nil {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
applyLibvirtDomainStat(current, strings.TrimSpace(key), strings.TrimSpace(value))
}
result := make([]agentshost.LibvirtDomain, 0, len(expected))
for _, name := range expectedNames {
if !validLibvirtDomainName(name) {
continue
}
key := strings.ToLower(name)
if domain := records[key]; domain != nil {
result = append(result, *domain)
} else {
result = append(result, agentshost.LibvirtDomain{
ID: libvirtDomainID(name),
Name: name,
State: "unknown",
})
}
if len(result) == libvirtMaxDomains {
break
}
}
return result, nil
}
func applyLibvirtDomainStat(domain *agentshost.LibvirtDomain, key, value string) {
if domain == nil {
return
}
switch key {
case "state.state":
if state, ok := parseLibvirtUint(value); ok {
domain.State = libvirtDomainState(state)
}
case "cpu.time":
if parsed, ok := parseLibvirtUint(value); ok {
domain.CPUTimeNanoseconds = parsed
}
case "vcpu.current":
if parsed, ok := parseLibvirtUint(value); ok && parsed <= math.MaxInt32 {
domain.VCPUs = int(parsed)
}
case "balloon.current":
if parsed, ok := parseLibvirtUint(value); ok {
domain.MemoryCurrentBytes = libvirtKiBToBytes(parsed)
}
case "balloon.maximum":
if parsed, ok := parseLibvirtUint(value); ok {
domain.MemoryMaximumBytes = libvirtKiBToBytes(parsed)
}
default:
parsed, ok := parseLibvirtUint(value)
if !ok {
return
}
switch {
case strings.HasPrefix(key, "net.") && strings.HasSuffix(key, ".rx.bytes"):
domain.NetworkRXBytes = saturatingAddUint64(domain.NetworkRXBytes, parsed)
case strings.HasPrefix(key, "net.") && strings.HasSuffix(key, ".tx.bytes"):
domain.NetworkTXBytes = saturatingAddUint64(domain.NetworkTXBytes, parsed)
case strings.HasPrefix(key, "block.") && strings.HasSuffix(key, ".rd.bytes"):
domain.DiskReadBytes = saturatingAddUint64(domain.DiskReadBytes, parsed)
case strings.HasPrefix(key, "block.") && strings.HasSuffix(key, ".wr.bytes"):
domain.DiskWriteBytes = saturatingAddUint64(domain.DiskWriteBytes, parsed)
}
}
}
func parseLibvirtUint(value string) (uint64, bool) {
value = strings.TrimSpace(strings.Trim(value, "'\""))
parsed, err := strconv.ParseUint(value, 10, 64)
return parsed, err == nil
}
func libvirtDomainState(value uint64) string {
switch value {
case 1:
return "running"
case 2:
return "blocked"
case 3:
return "paused"
case 4:
return "shutting-down"
case 5:
return "shutoff"
case 6:
return "crashed"
case 7:
return "suspended"
default:
return "unknown"
}
}
func libvirtKiBToBytes(value uint64) int64 {
if value > uint64(math.MaxInt64/1024) {
return math.MaxInt64
}
return int64(value * 1024)
}
func saturatingAddUint64(left, right uint64) uint64 {
if math.MaxUint64-left < right {
return math.MaxUint64
}
return left + right
}
func libvirtDomainID(name string) string {
sum := sha256.Sum256([]byte(name))
return "domain-" + hex.EncodeToString(sum[:8])
}