mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-06 23:47:17 +00:00
337 lines
8.3 KiB
Go
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])
|
|
}
|