first commit
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxReportedIncusInstances = 1000
|
||||
incusStateConcurrency = 8
|
||||
)
|
||||
|
||||
var externalGuestInterfacePattern = regexp.MustCompile(`^(eth[0-9]+|en(?:o|p|s|x)[a-z0-9]+)$`)
|
||||
|
||||
type incusAPIResponse struct {
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
}
|
||||
|
||||
type incusInstanceSummary struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
StatusCode int `json:"status_code"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type incusInstanceState struct {
|
||||
Status string `json:"status"`
|
||||
Network map[string]incusNetworkDevice `json:"network"`
|
||||
}
|
||||
|
||||
type incusNetworkDevice struct {
|
||||
Addresses []incusNetworkAddress `json:"addresses"`
|
||||
Hwaddr string `json:"hwaddr"`
|
||||
Counters incusNetworkCounters `json:"counters"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
type incusNetworkAddress struct {
|
||||
Family string `json:"family"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
type incusNetworkCounters struct {
|
||||
BytesReceived json.Number `json:"bytes_received"`
|
||||
BytesSent json.Number `json:"bytes_sent"`
|
||||
}
|
||||
|
||||
type trafficCounters struct {
|
||||
rx uint64
|
||||
tx uint64
|
||||
}
|
||||
|
||||
func collectIncusInstanceReport() map[string]any {
|
||||
reportedAt := time.Now().UTC().Format(time.RFC3339)
|
||||
socketPath, ok := detectIncusSocket()
|
||||
if !ok {
|
||||
return map[string]any{
|
||||
"available": false,
|
||||
"reportedAt": reportedAt,
|
||||
"total": 0,
|
||||
"items": []any{},
|
||||
}
|
||||
}
|
||||
|
||||
// 只通过本机 Unix socket 做只读采集,不要求宿主机开放额外 Agent 端口。
|
||||
client := newIncusUnixHTTPClient(socketPath)
|
||||
instances, err := listIncusInstances(client)
|
||||
if err != nil {
|
||||
return map[string]any{
|
||||
"available": false,
|
||||
"reportedAt": reportedAt,
|
||||
"total": 0,
|
||||
"items": []any{},
|
||||
"error": truncateReportError(err),
|
||||
}
|
||||
}
|
||||
|
||||
limitedInstances := instances
|
||||
if len(limitedInstances) > maxReportedIncusInstances {
|
||||
limitedInstances = limitedInstances[:maxReportedIncusInstances]
|
||||
}
|
||||
|
||||
items := make([]map[string]any, len(limitedInstances))
|
||||
var wg sync.WaitGroup
|
||||
semaphore := make(chan struct{}, incusStateConcurrency)
|
||||
|
||||
for index, instance := range limitedInstances {
|
||||
wg.Add(1)
|
||||
go func(index int, instance incusInstanceSummary) {
|
||||
defer wg.Done()
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
items[index] = buildIncusInstanceReportItem(client, instance)
|
||||
}(index, instance)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
normalizedItems := make([]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item != nil {
|
||||
normalizedItems = append(normalizedItems, item)
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"available": true,
|
||||
"reportedAt": reportedAt,
|
||||
"total": len(instances),
|
||||
"truncated": len(instances) > len(limitedInstances),
|
||||
"items": normalizedItems,
|
||||
}
|
||||
}
|
||||
|
||||
func newIncusUnixHTTPClient(socketPath string) *http.Client {
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, _network string, _addr string) (net.Conn, error) {
|
||||
var dialer net.Dialer
|
||||
return dialer.DialContext(ctx, "unix", socketPath)
|
||||
},
|
||||
DisableCompression: true,
|
||||
DisableKeepAlives: true,
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 8 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func listIncusInstances(client *http.Client) ([]incusInstanceSummary, error) {
|
||||
return incusRequest[[]incusInstanceSummary](client, "/1.0/instances?recursion=1")
|
||||
}
|
||||
|
||||
func getIncusInstanceState(client *http.Client, name string) (incusInstanceState, error) {
|
||||
return incusRequest[incusInstanceState](client, "/1.0/instances/"+url.PathEscape(name)+"/state")
|
||||
}
|
||||
|
||||
func incusRequest[T any](client *http.Client, path string) (T, error) {
|
||||
var zero T
|
||||
request, err := http.NewRequest(http.MethodGet, "http://incus"+path, nil)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return zero, fmt.Errorf("incus request failed: path=%s status=%d", path, response.StatusCode)
|
||||
}
|
||||
|
||||
var envelope incusAPIResponse
|
||||
decoder := json.NewDecoder(response.Body)
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&envelope); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
if envelope.Type == "error" {
|
||||
if envelope.Error != "" {
|
||||
return zero, fmt.Errorf("incus error: %s", envelope.Error)
|
||||
}
|
||||
return zero, fmt.Errorf("incus error: status=%s", envelope.Status)
|
||||
}
|
||||
|
||||
metadataDecoder := json.NewDecoder(bytes.NewReader(envelope.Metadata))
|
||||
metadataDecoder.UseNumber()
|
||||
if err := metadataDecoder.Decode(&zero); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
return zero, nil
|
||||
}
|
||||
|
||||
func buildIncusInstanceReportItem(client *http.Client, instance incusInstanceSummary) map[string]any {
|
||||
item := map[string]any{
|
||||
"name": instance.Name,
|
||||
"status": instance.Status,
|
||||
"statusCode": instance.StatusCode,
|
||||
"type": instance.Type,
|
||||
}
|
||||
|
||||
if instance.Name == "" {
|
||||
return item
|
||||
}
|
||||
|
||||
state, err := getIncusInstanceState(client, instance.Name)
|
||||
if err != nil {
|
||||
item["error"] = truncateReportError(err)
|
||||
return item
|
||||
}
|
||||
if state.Status != "" {
|
||||
item["status"] = state.Status
|
||||
}
|
||||
|
||||
counters := getTrafficCountersFromIncusState(instance.Name, state)
|
||||
item["traffic"] = map[string]any{
|
||||
"rxBytes": strconv.FormatUint(counters.rx, 10),
|
||||
"txBytes": strconv.FormatUint(counters.tx, 10),
|
||||
}
|
||||
|
||||
if ipv4, ipv6 := firstRoutableAddresses(state.Network); ipv4 != "" || ipv6 != "" {
|
||||
network := map[string]any{}
|
||||
if ipv4 != "" {
|
||||
network["ipv4"] = ipv4
|
||||
}
|
||||
if ipv6 != "" {
|
||||
network["ipv6"] = ipv6
|
||||
}
|
||||
item["network"] = network
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
func getTrafficCountersFromIncusState(instanceName string, state incusInstanceState) trafficCounters {
|
||||
billableVmMacs := generateBillableVmMacs(instanceName)
|
||||
totals := trafficCounters{}
|
||||
fallbackInterfaces := make([]incusNetworkDevice, 0)
|
||||
hasStrictBillableInterface := false
|
||||
|
||||
// 与面板旧采集口径保持一致,避免 guest 内部 bridge/veth 被重复计费。
|
||||
for ifName, ifData := range state.Network {
|
||||
if isBillableNetworkInterface(ifName, ifData, billableVmMacs) {
|
||||
hasStrictBillableInterface = true
|
||||
addNetworkCounters(&totals, ifData.Counters)
|
||||
continue
|
||||
}
|
||||
|
||||
if isLikelyExternalGuestInterface(ifName) {
|
||||
fallbackInterfaces = append(fallbackInterfaces, ifData)
|
||||
}
|
||||
}
|
||||
|
||||
if !hasStrictBillableInterface {
|
||||
for _, ifData := range fallbackInterfaces {
|
||||
addNetworkCounters(&totals, ifData.Counters)
|
||||
}
|
||||
}
|
||||
|
||||
return totals
|
||||
}
|
||||
|
||||
func isBillableNetworkInterface(ifName string, ifData incusNetworkDevice, billableVmMacs map[string]struct{}) bool {
|
||||
if ifName == "lo" {
|
||||
return false
|
||||
}
|
||||
if ifName == "eth0" || ifName == "eth1" {
|
||||
return true
|
||||
}
|
||||
|
||||
hwaddr := strings.ToLower(strings.TrimSpace(ifData.Hwaddr))
|
||||
if hwaddr == "" {
|
||||
return false
|
||||
}
|
||||
_, ok := billableVmMacs[hwaddr]
|
||||
return ok
|
||||
}
|
||||
|
||||
func isLikelyExternalGuestInterface(ifName string) bool {
|
||||
return externalGuestInterfacePattern.MatchString(strings.ToLower(ifName))
|
||||
}
|
||||
|
||||
func addNetworkCounters(totals *trafficCounters, counters incusNetworkCounters) {
|
||||
totals.rx += jsonNumberToUint64(counters.BytesReceived)
|
||||
totals.tx += jsonNumberToUint64(counters.BytesSent)
|
||||
}
|
||||
|
||||
func jsonNumberToUint64(value json.Number) uint64 {
|
||||
raw := strings.TrimSpace(value.String())
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
parsed, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err == nil {
|
||||
return parsed
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func generateBillableVmMacs(seed string) map[string]struct{} {
|
||||
return map[string]struct{}{
|
||||
generateVmNicMac(seed, "eth0"): {},
|
||||
generateVmNicMac(seed, "eth1"): {},
|
||||
}
|
||||
}
|
||||
|
||||
func generateVmNicMac(seed string, nicLabel string) string {
|
||||
hash := sha256.Sum256([]byte("incudal-vm-nic:" + seed + ":" + nicLabel))
|
||||
bytes := []byte{0x02, hash[0], hash[1], hash[2], hash[3], hash[4]}
|
||||
encoded := hex.EncodeToString(bytes)
|
||||
return strings.Join([]string{
|
||||
encoded[0:2],
|
||||
encoded[2:4],
|
||||
encoded[4:6],
|
||||
encoded[6:8],
|
||||
encoded[8:10],
|
||||
encoded[10:12],
|
||||
}, ":")
|
||||
}
|
||||
|
||||
func firstRoutableAddresses(network map[string]incusNetworkDevice) (string, string) {
|
||||
var ipv4 string
|
||||
var ipv6 string
|
||||
|
||||
for _, ifData := range network {
|
||||
for _, address := range ifData.Addresses {
|
||||
if address.Address == "" {
|
||||
continue
|
||||
}
|
||||
ip := net.ParseIP(address.Address)
|
||||
if ip == nil || !isRoutableGuestIP(ip) {
|
||||
continue
|
||||
}
|
||||
if ipv4 == "" && ip.To4() != nil && strings.EqualFold(address.Family, "inet") {
|
||||
ipv4 = address.Address
|
||||
continue
|
||||
}
|
||||
if ipv6 == "" && ip.To4() == nil && strings.EqualFold(address.Family, "inet6") {
|
||||
ipv6 = address.Address
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ipv4, ipv6
|
||||
}
|
||||
|
||||
func isRoutableGuestIP(ip net.IP) bool {
|
||||
return !ip.IsLoopback() &&
|
||||
!ip.IsUnspecified() &&
|
||||
!ip.IsLinkLocalUnicast() &&
|
||||
!ip.IsLinkLocalMulticast() &&
|
||||
!ip.IsMulticast()
|
||||
}
|
||||
|
||||
func truncateReportError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
message := err.Error()
|
||||
if len(message) > 200 {
|
||||
return message[:200]
|
||||
}
|
||||
return message
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
var incusSocketCandidates = []string{
|
||||
"/var/lib/incus/unix.socket",
|
||||
"/var/snap/incus/common/lxd/unix.socket",
|
||||
"/var/lib/lxd/unix.socket",
|
||||
}
|
||||
|
||||
func HeartbeatPayload(version string, heartbeatIntervalSeconds int) map[string]any {
|
||||
return map[string]any{
|
||||
"version": version,
|
||||
"capabilities": []any{"heartbeat", "report", "host-metrics", "instance-status", "traffic-counters"},
|
||||
"runtime": map[string]any{
|
||||
"goos": runtime.GOOS,
|
||||
"goarch": runtime.GOARCH,
|
||||
},
|
||||
"incus": detectIncus(),
|
||||
"instances": collectIncusInstanceReport(),
|
||||
"resources": collectResources(),
|
||||
"metrics": collectMetrics(heartbeatIntervalSeconds),
|
||||
}
|
||||
}
|
||||
|
||||
func collectResources() map[string]any {
|
||||
resources := map[string]any{
|
||||
"cpuTotal": runtime.NumCPU(),
|
||||
}
|
||||
if cpuUsagePercent := readCPUUsagePercent(); cpuUsagePercent >= 0 {
|
||||
resources["cpuUsagePercent"] = cpuUsagePercent
|
||||
}
|
||||
for key, value := range readMemoryStats() {
|
||||
resources[key] = value
|
||||
}
|
||||
for key, value := range readDiskStats("/") {
|
||||
resources[key] = value
|
||||
}
|
||||
if processCount := readProcessCount(); processCount >= 0 {
|
||||
resources["processCount"] = processCount
|
||||
}
|
||||
return resources
|
||||
}
|
||||
|
||||
func collectMetrics(heartbeatIntervalSeconds int) map[string]any {
|
||||
switch {
|
||||
case heartbeatIntervalSeconds <= 0:
|
||||
heartbeatIntervalSeconds = 30
|
||||
case heartbeatIntervalSeconds < 5:
|
||||
heartbeatIntervalSeconds = 5
|
||||
case heartbeatIntervalSeconds > 3600:
|
||||
heartbeatIntervalSeconds = 3600
|
||||
}
|
||||
|
||||
metrics := map[string]any{
|
||||
"reportedAt": time.Now().UTC().Format(time.RFC3339),
|
||||
"heartbeatIntervalSeconds": heartbeatIntervalSeconds,
|
||||
}
|
||||
if uptimeSeconds := readUptimeSeconds(); uptimeSeconds > 0 {
|
||||
metrics["uptimeSeconds"] = uptimeSeconds
|
||||
}
|
||||
for key, value := range readLoadAverage() {
|
||||
metrics[key] = value
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
func detectIncus() map[string]any {
|
||||
socketPath, ok := detectIncusSocket()
|
||||
if ok {
|
||||
return map[string]any{
|
||||
"available": true,
|
||||
"socket": socketPath,
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"available": false,
|
||||
"socket": "",
|
||||
}
|
||||
}
|
||||
|
||||
func detectIncusSocket() (string, bool) {
|
||||
for _, socketPath := range incusSocketCandidates {
|
||||
if info, err := os.Stat(socketPath); err == nil && !info.IsDir() {
|
||||
return socketPath, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
type cpuStat struct {
|
||||
idle uint64
|
||||
total uint64
|
||||
}
|
||||
|
||||
func readCPUUsagePercent() float64 {
|
||||
before, ok := readCPUStat()
|
||||
if !ok {
|
||||
return -1
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
after, ok := readCPUStat()
|
||||
if !ok {
|
||||
return -1
|
||||
}
|
||||
|
||||
totalDelta := after.total - before.total
|
||||
idleDelta := after.idle - before.idle
|
||||
if totalDelta == 0 || idleDelta > totalDelta {
|
||||
return -1
|
||||
}
|
||||
|
||||
return roundPercent(float64(totalDelta-idleDelta) / float64(totalDelta) * 100)
|
||||
}
|
||||
|
||||
func readCPUStat() (cpuStat, bool) {
|
||||
content, err := os.ReadFile("/proc/stat")
|
||||
if err != nil {
|
||||
return cpuStat{}, false
|
||||
}
|
||||
for _, line := range strings.Split(string(content), "\n") {
|
||||
if !strings.HasPrefix(line, "cpu ") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 5 {
|
||||
return cpuStat{}, false
|
||||
}
|
||||
|
||||
var values []uint64
|
||||
for _, field := range fields[1:] {
|
||||
value, err := strconv.ParseUint(field, 10, 64)
|
||||
if err != nil {
|
||||
return cpuStat{}, false
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
|
||||
var total uint64
|
||||
for _, value := range values {
|
||||
total += value
|
||||
}
|
||||
idle := values[3]
|
||||
if len(values) > 4 {
|
||||
idle += values[4]
|
||||
}
|
||||
return cpuStat{idle: idle, total: total}, true
|
||||
}
|
||||
return cpuStat{}, false
|
||||
}
|
||||
|
||||
func readMemoryStats() map[string]any {
|
||||
meminfo := readMeminfoKB()
|
||||
stats := map[string]any{}
|
||||
memTotal := meminfo["MemTotal"]
|
||||
memAvailable := meminfo["MemAvailable"]
|
||||
if memTotal > 0 {
|
||||
memUsed := memTotal - memAvailable
|
||||
if memUsed < 0 {
|
||||
memUsed = 0
|
||||
}
|
||||
stats["memoryTotalMb"] = memTotal / 1024
|
||||
stats["memoryAvailableMb"] = memAvailable / 1024
|
||||
stats["memoryUsedMb"] = memUsed / 1024
|
||||
stats["memoryUsagePercent"] = roundPercent(float64(memUsed) / float64(memTotal) * 100)
|
||||
}
|
||||
|
||||
swapTotal := meminfo["SwapTotal"]
|
||||
swapFree := meminfo["SwapFree"]
|
||||
if swapTotal > 0 {
|
||||
swapUsed := swapTotal - swapFree
|
||||
if swapUsed < 0 {
|
||||
swapUsed = 0
|
||||
}
|
||||
stats["swapTotalMb"] = swapTotal / 1024
|
||||
stats["swapUsedMb"] = swapUsed / 1024
|
||||
stats["swapUsagePercent"] = roundPercent(float64(swapUsed) / float64(swapTotal) * 100)
|
||||
} else {
|
||||
stats["swapTotalMb"] = int64(0)
|
||||
stats["swapUsedMb"] = int64(0)
|
||||
stats["swapUsagePercent"] = float64(0)
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
func readMeminfoKB() map[string]int64 {
|
||||
content, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return map[string]int64{}
|
||||
}
|
||||
|
||||
values := map[string]int64{}
|
||||
for _, line := range strings.Split(string(content), "\n") {
|
||||
key, rest, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(rest)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
kb, err := strconv.ParseInt(fields[0], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
values[key] = kb
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func readDiskStats(path string) map[string]any {
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &stat); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
blockSize := uint64(stat.Bsize)
|
||||
total := stat.Blocks * blockSize
|
||||
free := stat.Bfree * blockSize
|
||||
if total == 0 || free > total {
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
used := total - free
|
||||
return map[string]any{
|
||||
"diskMountpoint": path,
|
||||
"diskTotalBytes": total,
|
||||
"diskUsedBytes": used,
|
||||
"diskAvailableBytes": stat.Bavail * blockSize,
|
||||
"diskUsagePercent": roundPercent(float64(used) / float64(total) * 100),
|
||||
}
|
||||
}
|
||||
|
||||
func readLoadAverage() map[string]any {
|
||||
content, err := os.ReadFile("/proc/loadavg")
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
fields := strings.Fields(string(content))
|
||||
if len(fields) < 3 {
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
loads := map[string]any{}
|
||||
keys := []string{"load1", "load5", "load15"}
|
||||
for index, key := range keys {
|
||||
value, err := strconv.ParseFloat(fields[index], 64)
|
||||
if err == nil {
|
||||
loads[key] = value
|
||||
}
|
||||
}
|
||||
return loads
|
||||
}
|
||||
|
||||
func readProcessCount() int {
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if _, err := strconv.Atoi(entry.Name()); err == nil {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func readUptimeSeconds() int64 {
|
||||
content, err := os.ReadFile("/proc/uptime")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
fields := strings.Fields(string(content))
|
||||
if len(fields) == 0 {
|
||||
return 0
|
||||
}
|
||||
value, err := strconv.ParseFloat(fields[0], 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int64(value)
|
||||
}
|
||||
|
||||
func roundPercent(value float64) float64 {
|
||||
return float64(int(value*10+0.5)) / 10
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package report
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHeartbeatPayloadIncludesHostMetrics(t *testing.T) {
|
||||
payload := HeartbeatPayload("test-version", 30)
|
||||
|
||||
capabilities, ok := payload["capabilities"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("capabilities missing or invalid: %#v", payload["capabilities"])
|
||||
}
|
||||
if !containsCapability(capabilities, "host-metrics") {
|
||||
t.Fatalf("host-metrics capability missing: %#v", capabilities)
|
||||
}
|
||||
if !containsCapability(capabilities, "instance-status") {
|
||||
t.Fatalf("instance-status capability missing: %#v", capabilities)
|
||||
}
|
||||
if !containsCapability(capabilities, "traffic-counters") {
|
||||
t.Fatalf("traffic-counters capability missing: %#v", capabilities)
|
||||
}
|
||||
|
||||
instances, ok := payload["instances"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("instances report missing or invalid: %#v", payload["instances"])
|
||||
}
|
||||
for _, key := range []string{"available", "reportedAt", "total", "items"} {
|
||||
if _, ok := instances[key]; !ok {
|
||||
t.Fatalf("instances key %s missing: %#v", key, instances)
|
||||
}
|
||||
}
|
||||
|
||||
runtimeInfo, ok := payload["runtime"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("runtime missing or invalid: %#v", payload["runtime"])
|
||||
}
|
||||
for _, key := range []string{"goos", "goarch"} {
|
||||
if _, ok := runtimeInfo[key]; !ok {
|
||||
t.Fatalf("runtime key %s missing: %#v", key, runtimeInfo)
|
||||
}
|
||||
}
|
||||
|
||||
resources, ok := payload["resources"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("resources missing or invalid: %#v", payload["resources"])
|
||||
}
|
||||
for _, key := range []string{
|
||||
"cpuTotal",
|
||||
"cpuUsagePercent",
|
||||
"memoryTotalMb",
|
||||
"memoryUsedMb",
|
||||
"memoryUsagePercent",
|
||||
"swapTotalMb",
|
||||
"swapUsedMb",
|
||||
"swapUsagePercent",
|
||||
"diskTotalBytes",
|
||||
"diskUsedBytes",
|
||||
"diskUsagePercent",
|
||||
"processCount",
|
||||
} {
|
||||
if _, ok := resources[key]; !ok {
|
||||
t.Fatalf("resource key %s missing: %#v", key, resources)
|
||||
}
|
||||
}
|
||||
|
||||
metrics, ok := payload["metrics"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("metrics missing or invalid: %#v", payload["metrics"])
|
||||
}
|
||||
for _, key := range []string{"reportedAt", "heartbeatIntervalSeconds", "uptimeSeconds", "load1", "load5", "load15"} {
|
||||
if _, ok := metrics[key]; !ok {
|
||||
t.Fatalf("metric key %s missing: %#v", key, metrics)
|
||||
}
|
||||
}
|
||||
if metrics["heartbeatIntervalSeconds"] != 30 {
|
||||
t.Fatalf("heartbeat interval mismatch: %#v", metrics["heartbeatIntervalSeconds"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatPayloadClampsHeartbeatInterval(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input int
|
||||
expected int
|
||||
}{
|
||||
{name: "zero falls back", input: 0, expected: 30},
|
||||
{name: "too low clamps to min", input: 1, expected: 5},
|
||||
{name: "too high clamps to max", input: 7200, expected: 3600},
|
||||
{name: "valid stays unchanged", input: 60, expected: 60},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
payload := HeartbeatPayload("test-version", tt.input)
|
||||
metrics, ok := payload["metrics"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("metrics missing or invalid: %#v", payload["metrics"])
|
||||
}
|
||||
if metrics["heartbeatIntervalSeconds"] != tt.expected {
|
||||
t.Fatalf("heartbeat interval mismatch: got=%#v want=%d", metrics["heartbeatIntervalSeconds"], tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrafficCountersFromIncusStateUsesBillableInterfaces(t *testing.T) {
|
||||
state := incusInstanceState{
|
||||
Network: map[string]incusNetworkDevice{
|
||||
"lo": {
|
||||
Counters: incusNetworkCounters{
|
||||
BytesReceived: "999",
|
||||
BytesSent: "999",
|
||||
},
|
||||
},
|
||||
"eth0": {
|
||||
Counters: incusNetworkCounters{
|
||||
BytesReceived: "100",
|
||||
BytesSent: "200",
|
||||
},
|
||||
},
|
||||
"docker0": {
|
||||
Counters: incusNetworkCounters{
|
||||
BytesReceived: "300",
|
||||
BytesSent: "400",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
counters := getTrafficCountersFromIncusState("vm-test", state)
|
||||
if counters.rx != 100 || counters.tx != 200 {
|
||||
t.Fatalf("traffic counters mismatch: got=%+v", counters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrafficCountersFromIncusStateFallsBackToExternalInterfaces(t *testing.T) {
|
||||
state := incusInstanceState{
|
||||
Network: map[string]incusNetworkDevice{
|
||||
"lo": {
|
||||
Counters: incusNetworkCounters{
|
||||
BytesReceived: "999",
|
||||
BytesSent: "999",
|
||||
},
|
||||
},
|
||||
"enp5s0": {
|
||||
Counters: incusNetworkCounters{
|
||||
BytesReceived: "123",
|
||||
BytesSent: "456",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
counters := getTrafficCountersFromIncusState("vm-test", state)
|
||||
if counters.rx != 123 || counters.tx != 456 {
|
||||
t.Fatalf("traffic counters mismatch: got=%+v", counters)
|
||||
}
|
||||
}
|
||||
|
||||
func containsCapability(capabilities []any, expected string) bool {
|
||||
for _, capability := range capabilities {
|
||||
if capability == expected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user