mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 11:30:15 +00:00
ZFS zvols (zd*), device-mapper, virtio disks, and other virtual block devices don't support SMART and were being reported as FAILED. Use lsblk JSON metadata to filter by device prefix, transport, subsystem, and vendor/model. Also treat missing smart_status as unknown rather than failed, and ignore UNKNOWN health in Patrol/AI signals.
2753 lines
70 KiB
Go
2753 lines
70 KiB
Go
package chat
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// FactEntry is the output of ExtractFacts — ready to feed into KnowledgeAccumulator.AddFact.
|
|
type FactEntry struct {
|
|
Category FactCategory
|
|
Key string
|
|
Value string
|
|
}
|
|
|
|
// ExtractFacts deterministically extracts knowledge facts from a tool result.
|
|
// No LLM calls. Parses the JSON text from FormatToolResult() output.
|
|
// Returns empty slice on parse errors or unrecognized tools — never panics.
|
|
//
|
|
// Tool results use NewJSONResult (direct struct marshaling), NOT ToolResponse wrapper.
|
|
// So the resultText is the JSON of the response struct directly (e.g. ResourceResponse).
|
|
func ExtractFacts(toolName string, toolInput map[string]interface{}, resultText string) []FactEntry {
|
|
switch toolName {
|
|
case "pulse_query":
|
|
return extractQueryFacts(toolInput, resultText)
|
|
case "pulse_storage":
|
|
return extractStorageFacts(toolInput, resultText)
|
|
case "pulse_discovery":
|
|
return extractDiscoveryFacts(toolInput, resultText)
|
|
case "pulse_read", "pulse_run_command":
|
|
return extractExecFacts(toolInput, resultText)
|
|
case "pulse_metrics":
|
|
return extractMetricsFacts(toolInput, resultText)
|
|
case "pulse_alerts":
|
|
return extractAlertsFacts(toolInput, resultText)
|
|
case "pulse_docker":
|
|
return extractDockerFacts(toolInput, resultText)
|
|
case "pulse_kubernetes":
|
|
return extractKubernetesFacts(toolInput, resultText)
|
|
case "pulse_pmg":
|
|
return extractPMGFacts(toolInput, resultText)
|
|
case "patrol_report_finding":
|
|
return extractFindingFacts(toolInput, resultText)
|
|
case "patrol_get_findings":
|
|
return extractPatrolGetFindingsFacts(resultText)
|
|
default:
|
|
log.Debug().Str("tool", toolName).Msg("[KnowledgeExtractor] No extractor for tool")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// --- pulse_query ---
|
|
|
|
func extractQueryFacts(input map[string]interface{}, resultText string) []FactEntry {
|
|
action := strFromMap(input, "action")
|
|
if action == "" {
|
|
action = strFromMap(input, "type")
|
|
}
|
|
|
|
switch action {
|
|
case "get":
|
|
return extractQueryGetFacts(input, resultText)
|
|
case "search":
|
|
return extractQuerySearchFacts(input, resultText)
|
|
case "topology":
|
|
return extractQueryTopologyFacts(resultText)
|
|
case "health":
|
|
return extractQueryHealthFacts(resultText)
|
|
case "list":
|
|
return extractQueryListFacts(resultText)
|
|
case "config":
|
|
return extractQueryConfigFacts(input, resultText)
|
|
default:
|
|
log.Debug().Str("tool", "pulse_query").Str("action", action).
|
|
Msg("[KnowledgeExtractor] No extractor for action")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func extractQueryGetFacts(input map[string]interface{}, resultText string) []FactEntry {
|
|
// Tool results are direct JSON (NewJSONResult), no ToolResponse wrapper.
|
|
// ResourceResponse has nested CPU/Memory structs.
|
|
var resource struct {
|
|
Type string `json:"type"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Node string `json:"node"`
|
|
ID string `json:"id"`
|
|
VMID int `json:"vmid"`
|
|
Host string `json:"host"`
|
|
CPU struct {
|
|
Percent float64 `json:"percent"`
|
|
} `json:"cpu"`
|
|
Memory struct {
|
|
Percent float64 `json:"percent"`
|
|
} `json:"memory"`
|
|
// Error field for not-found responses
|
|
Error string `json:"error"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resource); err != nil {
|
|
return nil
|
|
}
|
|
|
|
// Return negative fact for error/not-found responses
|
|
if resource.Error != "" {
|
|
resourceID := strFromMap(input, "resource_id")
|
|
if resourceID == "" {
|
|
return nil
|
|
}
|
|
return []FactEntry{{
|
|
Category: FactCategoryResource,
|
|
Key: fmt.Sprintf("query:get:%s:error", resourceID),
|
|
Value: fmt.Sprintf("not found: %s", resource.Error),
|
|
}}
|
|
}
|
|
if resource.Name == "" && resource.ID == "" {
|
|
return nil
|
|
}
|
|
|
|
resType := resource.Type
|
|
if resType == "" {
|
|
resType = strFromMap(input, "resource_type")
|
|
}
|
|
node := resource.Node
|
|
if node == "" {
|
|
node = resource.Host
|
|
}
|
|
// Prefer VMID (numeric) over composite ID (which may contain node prefix like "delly:minipc:100")
|
|
id := ""
|
|
if resource.VMID > 0 {
|
|
id = fmt.Sprintf("%d", resource.VMID)
|
|
}
|
|
if id == "" {
|
|
// Fall back to resource_id from tool input (user-provided, e.g. "100")
|
|
id = strFromMap(input, "resource_id")
|
|
}
|
|
if id == "" {
|
|
id = resource.Name
|
|
}
|
|
|
|
key := fmt.Sprintf("%s:%s:%s:status", resType, node, id)
|
|
|
|
var parts []string
|
|
if resource.Status != "" {
|
|
parts = append(parts, resource.Status)
|
|
}
|
|
if resource.Name != "" {
|
|
parts = append(parts, resource.Name)
|
|
}
|
|
if resource.CPU.Percent > 0 {
|
|
parts = append(parts, fmt.Sprintf("CPU=%.1f%%", resource.CPU.Percent))
|
|
}
|
|
if resource.Memory.Percent > 0 {
|
|
parts = append(parts, fmt.Sprintf("Mem=%.1f%%", resource.Memory.Percent))
|
|
}
|
|
|
|
if len(parts) == 0 {
|
|
return nil
|
|
}
|
|
|
|
value := strings.Join(parts, ", ")
|
|
|
|
facts := []FactEntry{{
|
|
Category: FactCategoryResource,
|
|
Key: key,
|
|
Value: value,
|
|
}}
|
|
|
|
// Secondary fact with predictable key for gate matching
|
|
resourceID := strFromMap(input, "resource_id")
|
|
if resourceID != "" {
|
|
facts = append(facts, FactEntry{
|
|
Category: FactCategoryResource,
|
|
Key: fmt.Sprintf("query:get:%s:cached", resourceID),
|
|
Value: truncateValue(value),
|
|
})
|
|
}
|
|
|
|
return facts
|
|
}
|
|
|
|
func extractQuerySearchFacts(input map[string]interface{}, resultText string) []FactEntry {
|
|
query := strFromMap(input, "query")
|
|
if query == "" {
|
|
query = strFromMap(input, "search")
|
|
}
|
|
|
|
// ResourceSearchResponse — direct JSON, no wrapper
|
|
var resp struct {
|
|
Matches []struct {
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Type string `json:"type"`
|
|
} `json:"matches"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
total := resp.Total
|
|
if total == 0 && len(resp.Matches) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Summarize first 5 matches
|
|
var summaryParts []string
|
|
limit := 5
|
|
if len(resp.Matches) < limit {
|
|
limit = len(resp.Matches)
|
|
}
|
|
for _, m := range resp.Matches[:limit] {
|
|
entry := m.Name
|
|
if m.Status != "" {
|
|
entry += " (" + m.Status + ")"
|
|
}
|
|
summaryParts = append(summaryParts, entry)
|
|
}
|
|
|
|
value := fmt.Sprintf("%d results: %s", total, strings.Join(summaryParts, ", "))
|
|
|
|
return []FactEntry{{
|
|
Category: FactCategoryResource,
|
|
Key: fmt.Sprintf("search:%s:summary", query),
|
|
Value: truncateValue(value),
|
|
}}
|
|
}
|
|
|
|
func extractQueryTopologyFacts(resultText string) []FactEntry {
|
|
// TopologyResponse — direct JSON. Has summary + proxmox.nodes array.
|
|
// Real format: nodes are under "proxmox.nodes", LXC count is "total_lxc_containers".
|
|
var resp struct {
|
|
Summary struct {
|
|
TotalNodes int `json:"total_nodes"`
|
|
TotalVMs int `json:"total_vms"`
|
|
RunningVMs int `json:"running_vms"`
|
|
TotalLXCContainers int `json:"total_lxc_containers"`
|
|
RunningLXC int `json:"running_lxc"`
|
|
TotalDockerHost int `json:"total_docker_hosts"`
|
|
} `json:"summary"`
|
|
Proxmox struct {
|
|
Nodes []struct {
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
} `json:"nodes"`
|
|
} `json:"proxmox"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
s := resp.Summary
|
|
|
|
// Build node list
|
|
var nodeDescs []string
|
|
for _, n := range resp.Proxmox.Nodes {
|
|
status := n.Status
|
|
if status == "" {
|
|
status = "unknown"
|
|
}
|
|
nodeDescs = append(nodeDescs, fmt.Sprintf("%s=%s", n.Name, status))
|
|
}
|
|
|
|
var parts []string
|
|
if s.TotalNodes > 0 || len(resp.Proxmox.Nodes) > 0 {
|
|
nodeCount := s.TotalNodes
|
|
if nodeCount == 0 {
|
|
nodeCount = len(resp.Proxmox.Nodes)
|
|
}
|
|
nodeStr := fmt.Sprintf("%d nodes", nodeCount)
|
|
if len(nodeDescs) > 0 {
|
|
nodeStr += " (" + strings.Join(nodeDescs, ", ") + ")"
|
|
}
|
|
parts = append(parts, nodeStr)
|
|
}
|
|
if s.TotalVMs > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d VMs (%d running)", s.TotalVMs, s.RunningVMs))
|
|
}
|
|
if s.TotalLXCContainers > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d LXC (%d running)", s.TotalLXCContainers, s.RunningLXC))
|
|
}
|
|
if s.TotalDockerHost > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d docker host", s.TotalDockerHost))
|
|
}
|
|
|
|
if len(parts) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return []FactEntry{{
|
|
Category: FactCategoryResource,
|
|
Key: "topology:summary",
|
|
Value: truncateValue(strings.Join(parts, ", ")),
|
|
}}
|
|
}
|
|
|
|
func extractQueryHealthFacts(resultText string) []FactEntry {
|
|
// ConnectionHealthResponse — direct JSON.
|
|
// Real format uses "instance_id" as the identifier field.
|
|
var resp struct {
|
|
Connections []struct {
|
|
InstanceID string `json:"instance_id"`
|
|
Name string `json:"name"`
|
|
Instance string `json:"instance"`
|
|
Connected bool `json:"connected"`
|
|
Status string `json:"status"`
|
|
} `json:"connections"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
if len(resp.Connections) == 0 {
|
|
return nil
|
|
}
|
|
|
|
total := len(resp.Connections)
|
|
connected := 0
|
|
var disconnected []string
|
|
for _, c := range resp.Connections {
|
|
if c.Connected {
|
|
connected++
|
|
} else {
|
|
name := c.InstanceID
|
|
if name == "" {
|
|
name = c.Name
|
|
}
|
|
if name == "" {
|
|
name = c.Instance
|
|
}
|
|
if name != "" {
|
|
disconnected = append(disconnected, name)
|
|
}
|
|
}
|
|
}
|
|
|
|
value := fmt.Sprintf("%d/%d connected", connected, total)
|
|
if len(disconnected) > 0 {
|
|
value += ", disconnected: " + strings.Join(disconnected, ", ")
|
|
}
|
|
|
|
return []FactEntry{{
|
|
Category: FactCategoryResource,
|
|
Key: "health:connections",
|
|
Value: truncateValue(value),
|
|
}}
|
|
}
|
|
|
|
func extractQueryListFacts(resultText string) []FactEntry {
|
|
var resp struct {
|
|
Nodes []struct {
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
} `json:"nodes"`
|
|
VMs []struct {
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
} `json:"vms"`
|
|
Containers []struct {
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
} `json:"containers"`
|
|
DockerHosts []struct {
|
|
Hostname string `json:"hostname"`
|
|
DisplayName string `json:"display_name"`
|
|
ContainerCount int `json:"container_count"`
|
|
} `json:"docker_hosts"`
|
|
Total struct {
|
|
Nodes int `json:"nodes"`
|
|
VMs int `json:"vms"`
|
|
Containers int `json:"containers"`
|
|
DockerHosts int `json:"docker_hosts"`
|
|
} `json:"total"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
// Count running resources
|
|
runningVMs := 0
|
|
for _, vm := range resp.VMs {
|
|
if vm.Status == "running" {
|
|
runningVMs++
|
|
}
|
|
}
|
|
runningLXC := 0
|
|
for _, ct := range resp.Containers {
|
|
if ct.Status == "running" {
|
|
runningLXC++
|
|
}
|
|
}
|
|
|
|
var parts []string
|
|
nodeCount := resp.Total.Nodes
|
|
if nodeCount == 0 {
|
|
nodeCount = len(resp.Nodes)
|
|
}
|
|
vmCount := resp.Total.VMs
|
|
if vmCount == 0 {
|
|
vmCount = len(resp.VMs)
|
|
}
|
|
ctCount := resp.Total.Containers
|
|
if ctCount == 0 {
|
|
ctCount = len(resp.Containers)
|
|
}
|
|
|
|
if nodeCount > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d nodes", nodeCount))
|
|
}
|
|
if vmCount > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d VMs (%d running)", vmCount, runningVMs))
|
|
}
|
|
if ctCount > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d LXC (%d running)", ctCount, runningLXC))
|
|
}
|
|
dockerCount := resp.Total.DockerHosts
|
|
if dockerCount == 0 {
|
|
dockerCount = len(resp.DockerHosts)
|
|
}
|
|
if dockerCount > 0 {
|
|
totalContainers := 0
|
|
for _, dh := range resp.DockerHosts {
|
|
totalContainers += dh.ContainerCount
|
|
}
|
|
parts = append(parts, fmt.Sprintf("%d docker hosts (%d containers)", dockerCount, totalContainers))
|
|
}
|
|
|
|
if len(parts) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return []FactEntry{{
|
|
Category: FactCategoryResource,
|
|
Key: "inventory:summary",
|
|
Value: truncateValue(strings.Join(parts, ", ")),
|
|
}}
|
|
}
|
|
|
|
func extractQueryConfigFacts(input map[string]interface{}, resultText string) []FactEntry {
|
|
var resp struct {
|
|
GuestType string `json:"guest_type"`
|
|
VMID int `json:"vmid"`
|
|
Name string `json:"name"`
|
|
Node string `json:"node"`
|
|
Hostname string `json:"hostname"`
|
|
OSType string `json:"os_type"`
|
|
Onboot *bool `json:"onboot"`
|
|
Mounts []struct {
|
|
Key string `json:"key"`
|
|
Mountpoint string `json:"mountpoint"`
|
|
} `json:"mounts"`
|
|
Disks []struct {
|
|
Key string `json:"key"`
|
|
} `json:"disks"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
if resp.VMID == 0 && resp.Name == "" {
|
|
return nil
|
|
}
|
|
|
|
id := fmt.Sprintf("%d", resp.VMID)
|
|
if id == "0" {
|
|
id = resp.Name
|
|
}
|
|
|
|
var parts []string
|
|
if resp.GuestType != "" {
|
|
parts = append(parts, resp.GuestType)
|
|
}
|
|
if resp.Hostname != "" {
|
|
parts = append(parts, "hostname="+resp.Hostname)
|
|
}
|
|
if resp.OSType != "" {
|
|
parts = append(parts, "os="+resp.OSType)
|
|
}
|
|
if resp.Onboot != nil {
|
|
if *resp.Onboot {
|
|
parts = append(parts, "onboot=yes")
|
|
} else {
|
|
parts = append(parts, "onboot=no")
|
|
}
|
|
}
|
|
if len(resp.Mounts) > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d mounts", len(resp.Mounts)))
|
|
}
|
|
if len(resp.Disks) > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d disks", len(resp.Disks)))
|
|
}
|
|
|
|
if len(parts) == 0 {
|
|
return nil
|
|
}
|
|
|
|
facts := []FactEntry{{
|
|
Category: FactCategoryResource,
|
|
Key: fmt.Sprintf("config:%s:%s", resp.Node, id),
|
|
Value: truncateValue(strings.Join(parts, ", ")),
|
|
}}
|
|
|
|
// Secondary fact with predictable key for gate matching.
|
|
// The model often omits "node" from the tool input, so config:{node}:{id}
|
|
// can't be predicted. This key uses just the resource_id.
|
|
resourceID := strFromMap(input, "resource_id")
|
|
if resourceID != "" {
|
|
facts = append(facts, FactEntry{
|
|
Category: FactCategoryResource,
|
|
Key: fmt.Sprintf("config:%s:cached", resourceID),
|
|
Value: truncateValue(strings.Join(parts, ", ")),
|
|
})
|
|
}
|
|
|
|
return facts
|
|
}
|
|
|
|
// categoryForPredictedKey infers the FactCategory from a predicted key prefix.
|
|
// Used when storing negative markers for text/error responses.
|
|
func categoryForPredictedKey(key string) FactCategory {
|
|
switch {
|
|
case strings.HasPrefix(key, "storage:") || strings.HasPrefix(key, "disk_health:") ||
|
|
strings.HasPrefix(key, "raid:") || strings.HasPrefix(key, "backups:") ||
|
|
strings.HasPrefix(key, "physical_disks:") || strings.HasPrefix(key, "ceph:") ||
|
|
strings.HasPrefix(key, "ceph_details:") || strings.HasPrefix(key, "snapshots:") ||
|
|
strings.HasPrefix(key, "replication:") || strings.HasPrefix(key, "pbs_jobs:") ||
|
|
strings.HasPrefix(key, "resource_disks:") || strings.HasPrefix(key, "backup_tasks:") ||
|
|
strings.HasPrefix(key, "backup:"):
|
|
return FactCategoryStorage
|
|
case strings.HasPrefix(key, "metrics:") || strings.HasPrefix(key, "baseline:") ||
|
|
strings.HasPrefix(key, "baselines:") || strings.HasPrefix(key, "temperatures:"):
|
|
return FactCategoryMetrics
|
|
case strings.HasPrefix(key, "exec:"):
|
|
return FactCategoryExec
|
|
case strings.HasPrefix(key, "discovery:"):
|
|
return FactCategoryDiscovery
|
|
case strings.HasPrefix(key, "topology:") || strings.HasPrefix(key, "health:") ||
|
|
strings.HasPrefix(key, "search:") || strings.HasPrefix(key, "query:") ||
|
|
strings.HasPrefix(key, "inventory:") || strings.HasPrefix(key, "config:") ||
|
|
strings.HasPrefix(key, "docker_") || strings.HasPrefix(key, "k8s_") ||
|
|
strings.HasPrefix(key, "pmg:") || strings.HasPrefix(key, "pmg_"):
|
|
return FactCategoryResource
|
|
case strings.HasPrefix(key, "finding:") || strings.HasPrefix(key, "findings:") ||
|
|
strings.HasPrefix(key, "patrol_findings:"):
|
|
return FactCategoryFinding
|
|
case strings.HasPrefix(key, "alert:") || strings.HasPrefix(key, "alerts:"):
|
|
return FactCategoryAlert
|
|
default:
|
|
return FactCategoryResource
|
|
}
|
|
}
|
|
|
|
// --- pulse_storage ---
|
|
|
|
func extractStorageFacts(input map[string]interface{}, resultText string) []FactEntry {
|
|
action := strFromMap(input, "action")
|
|
if action == "" {
|
|
action = strFromMap(input, "type")
|
|
}
|
|
|
|
switch action {
|
|
case "pools":
|
|
return extractStoragePoolFacts(resultText)
|
|
case "backup_tasks":
|
|
return extractBackupTaskFacts(resultText)
|
|
case "disk_health":
|
|
return extractDiskHealthFacts(resultText)
|
|
case "raid":
|
|
return extractStorageRAIDFacts(resultText)
|
|
case "backups":
|
|
return extractStorageBackupsFacts(resultText)
|
|
case "ceph":
|
|
return extractCephFacts(resultText)
|
|
case "ceph_details":
|
|
return extractCephDetailsFacts(resultText)
|
|
case "snapshots":
|
|
return extractSnapshotsFacts(resultText)
|
|
case "replication":
|
|
return extractReplicationFacts(resultText)
|
|
case "pbs_jobs":
|
|
return extractPBSJobsFacts(resultText)
|
|
case "resource_disks":
|
|
return extractResourceDisksFacts(resultText)
|
|
default:
|
|
log.Debug().Str("tool", "pulse_storage").Str("action", action).
|
|
Msg("[KnowledgeExtractor] No extractor for action")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func extractStoragePoolFacts(resultText string) []FactEntry {
|
|
// StorageResponse — direct JSON, no wrapper
|
|
var resp struct {
|
|
Pools []struct {
|
|
Name string `json:"name"`
|
|
Node string `json:"node"`
|
|
Nodes []string `json:"nodes"`
|
|
Type string `json:"type"`
|
|
Status string `json:"status"`
|
|
Active bool `json:"active"`
|
|
UsagePercent float64 `json:"usage_percent"`
|
|
TotalGB float64 `json:"total_gb"`
|
|
UsedGB float64 `json:"used_gb"`
|
|
} `json:"pools"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
// Emit marker fact so PredictFactKeys can gate repeat calls
|
|
var facts []FactEntry
|
|
facts = append(facts, FactEntry{
|
|
Category: FactCategoryStorage,
|
|
Key: "storage:pools:queried",
|
|
Value: fmt.Sprintf("%d pools extracted", len(resp.Pools)),
|
|
})
|
|
|
|
for _, pool := range resp.Pools {
|
|
node := pool.Node
|
|
if node == "" && len(pool.Nodes) > 0 {
|
|
node = strings.Join(pool.Nodes, "+")
|
|
}
|
|
|
|
freeGB := pool.TotalGB - pool.UsedGB
|
|
var parts []string
|
|
if pool.Type != "" {
|
|
parts = append(parts, pool.Type)
|
|
}
|
|
if pool.Status != "" {
|
|
parts = append(parts, pool.Status)
|
|
}
|
|
if pool.Active {
|
|
parts = append(parts, fmt.Sprintf("active on %s", node))
|
|
}
|
|
parts = append(parts, fmt.Sprintf("%.1f%% used", pool.UsagePercent))
|
|
if freeGB > 0 {
|
|
parts = append(parts, fmt.Sprintf("%.0fGB free", freeGB))
|
|
}
|
|
|
|
facts = append(facts, FactEntry{
|
|
Category: FactCategoryStorage,
|
|
Key: fmt.Sprintf("storage:%s:%s", node, pool.Name),
|
|
Value: truncateValue(strings.Join(parts, ", ")),
|
|
})
|
|
}
|
|
return facts
|
|
}
|
|
|
|
func extractBackupTaskFacts(resultText string) []FactEntry {
|
|
// BackupTasksListResponse — direct JSON, no wrapper
|
|
// Note: vmid is an int in the API response (can be 0 for cluster-level tasks)
|
|
var resp struct {
|
|
Tasks []struct {
|
|
VMID int `json:"vmid"`
|
|
Node string `json:"node"`
|
|
Status string `json:"status"`
|
|
StartTime string `json:"start_time"`
|
|
Error string `json:"error"`
|
|
} `json:"tasks"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
total := resp.Total
|
|
if total == 0 {
|
|
total = len(resp.Tasks)
|
|
}
|
|
|
|
// Count failures — status is case-insensitive
|
|
failed := 0
|
|
for _, task := range resp.Tasks {
|
|
s := strings.ToUpper(task.Status)
|
|
if s != "OK" && s != "SUCCESS" && s != "" {
|
|
failed++
|
|
}
|
|
}
|
|
|
|
markerFact := FactEntry{
|
|
Category: FactCategoryStorage,
|
|
Key: "backup_tasks:queried",
|
|
Value: fmt.Sprintf("%d tasks, %d failed", total, failed),
|
|
}
|
|
|
|
facts := []FactEntry{markerFact}
|
|
for _, task := range resp.Tasks {
|
|
// Only record failures as individual facts
|
|
s := strings.ToUpper(task.Status)
|
|
if s == "OK" || s == "SUCCESS" || s == "" {
|
|
continue
|
|
}
|
|
var parts []string
|
|
parts = append(parts, task.Status)
|
|
if task.StartTime != "" {
|
|
parts = append(parts, "at "+task.StartTime)
|
|
}
|
|
if task.Error != "" {
|
|
parts = append(parts, "error="+task.Error)
|
|
}
|
|
vmidStr := fmt.Sprintf("%d", task.VMID)
|
|
facts = append(facts, FactEntry{
|
|
Category: FactCategoryStorage,
|
|
Key: fmt.Sprintf("backup:%s:%s", vmidStr, task.Node),
|
|
Value: truncateValue(strings.Join(parts, ", ")),
|
|
})
|
|
}
|
|
return facts
|
|
}
|
|
|
|
// --- pulse_discovery ---
|
|
|
|
func extractDiscoveryFacts(input map[string]interface{}, resultText string) []FactEntry {
|
|
// ResourceDiscoveryInfo — direct JSON, no wrapper
|
|
var disc struct {
|
|
ServiceType string `json:"service_type"`
|
|
Hostname string `json:"hostname"`
|
|
HostID string `json:"host_id"`
|
|
ResourceID string `json:"resource_id"`
|
|
Ports []struct {
|
|
Port int `json:"port"`
|
|
} `json:"ports"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &disc); err != nil {
|
|
return nil
|
|
}
|
|
|
|
host := disc.HostID
|
|
if host == "" {
|
|
host = strFromMap(input, "host")
|
|
}
|
|
id := disc.ResourceID
|
|
if id == "" {
|
|
id = strFromMap(input, "resource_id")
|
|
}
|
|
|
|
var parts []string
|
|
if disc.ServiceType != "" {
|
|
parts = append(parts, "service="+disc.ServiceType)
|
|
}
|
|
if disc.Hostname != "" {
|
|
parts = append(parts, "hostname="+disc.Hostname)
|
|
}
|
|
if len(disc.Ports) > 0 {
|
|
var portStrs []string
|
|
for _, p := range disc.Ports {
|
|
portStrs = append(portStrs, fmt.Sprintf("%d", p.Port))
|
|
}
|
|
parts = append(parts, "ports=["+strings.Join(portStrs, ",")+"]")
|
|
}
|
|
|
|
if len(parts) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return []FactEntry{{
|
|
Category: FactCategoryDiscovery,
|
|
Key: fmt.Sprintf("discovery:%s:%s", host, id),
|
|
Value: truncateValue(strings.Join(parts, ", ")),
|
|
}}
|
|
}
|
|
|
|
// --- pulse_read / pulse_run_command ---
|
|
|
|
// buildExecKeyCmd derives the command portion of the KA fact key from tool input.
|
|
// Shared by extractExecFacts and PredictFactKeys to ensure consistent key generation.
|
|
// Returns empty string if no distinguishing command/action can be determined.
|
|
func buildExecKeyCmd(input map[string]interface{}) string {
|
|
cmd := strFromMap(input, "command")
|
|
if cmd == "" {
|
|
action := strFromMap(input, "action")
|
|
path := strFromMap(input, "path")
|
|
if action != "" && path != "" {
|
|
cmd = action + ":" + path
|
|
} else if action == "logs" {
|
|
// For log queries, include distinguishing params to avoid key collisions.
|
|
// Different since/grep/source/unit combos must produce different keys.
|
|
var parts []string
|
|
parts = append(parts, "logs")
|
|
for _, param := range []string{"since", "grep", "source", "unit"} {
|
|
if v := strFromMap(input, param); v != "" {
|
|
parts = append(parts, param+"="+v)
|
|
}
|
|
}
|
|
cmd = strings.Join(parts, ":")
|
|
} else if action != "" {
|
|
cmd = action
|
|
}
|
|
}
|
|
if cmd == "" {
|
|
return ""
|
|
}
|
|
if len(cmd) > 60 {
|
|
cmd = cmd[:60]
|
|
}
|
|
return cmd
|
|
}
|
|
|
|
func extractExecFacts(input map[string]interface{}, resultText string) []FactEntry {
|
|
host := strFromMap(input, "target_host")
|
|
if host == "" {
|
|
host = strFromMap(input, "host")
|
|
}
|
|
cmdPrefix := buildExecKeyCmd(input)
|
|
if cmdPrefix == "" {
|
|
return nil
|
|
}
|
|
|
|
// Try to parse as CommandResponse (direct JSON, no wrapper)
|
|
var cmdResp struct {
|
|
Success bool `json:"success"`
|
|
ExitCode int `json:"exit_code"`
|
|
Output string `json:"output"`
|
|
Stdout string `json:"stdout"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
var value string
|
|
if err := json.Unmarshal([]byte(resultText), &cmdResp); err == nil && (cmdResp.Output != "" || cmdResp.Stdout != "" || cmdResp.Error != "") {
|
|
output := cmdResp.Output
|
|
if output == "" {
|
|
output = cmdResp.Stdout
|
|
}
|
|
if output == "" {
|
|
output = cmdResp.Error
|
|
}
|
|
// Take first 2 lines
|
|
lines := strings.SplitN(output, "\n", 3)
|
|
summary := strings.Join(lines[:min(2, len(lines))], "; ")
|
|
value = fmt.Sprintf("exit=%d, %s", cmdResp.ExitCode, summary)
|
|
} else {
|
|
// Fallback: use first 2 lines of raw result text
|
|
lines := strings.SplitN(resultText, "\n", 3)
|
|
summary := strings.Join(lines[:min(2, len(lines))], "; ")
|
|
value = summary
|
|
}
|
|
|
|
return []FactEntry{{
|
|
Category: FactCategoryExec,
|
|
Key: fmt.Sprintf("exec:%s:%s", host, cmdPrefix),
|
|
Value: truncateValue(value),
|
|
}}
|
|
}
|
|
|
|
// --- pulse_metrics ---
|
|
|
|
func extractMetricsFacts(input map[string]interface{}, resultText string) []FactEntry {
|
|
action := strFromMap(input, "action")
|
|
if action == "" {
|
|
action = strFromMap(input, "type")
|
|
}
|
|
|
|
switch action {
|
|
case "performance":
|
|
return extractMetricsPerformanceFacts(input, resultText)
|
|
case "baselines":
|
|
return extractMetricsBaselinesFacts(resultText)
|
|
case "disks":
|
|
return extractMetricsDisksFacts(resultText)
|
|
case "temperatures":
|
|
return extractMetricsTemperaturesFacts(resultText)
|
|
default:
|
|
log.Debug().Str("tool", "pulse_metrics").Str("action", action).
|
|
Msg("[KnowledgeExtractor] No extractor for action")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func extractMetricsPerformanceFacts(input map[string]interface{}, resultText string) []FactEntry {
|
|
resourceID := strFromMap(input, "resource_id")
|
|
if resourceID == "" {
|
|
return nil
|
|
}
|
|
|
|
var resp struct {
|
|
ResourceID string `json:"resource_id"`
|
|
Period string `json:"period"`
|
|
Points []struct {
|
|
CPU float64 `json:"cpu"`
|
|
Memory float64 `json:"memory"`
|
|
Disk float64 `json:"disk"`
|
|
} `json:"points"`
|
|
Summary map[string]struct {
|
|
AvgCPU float64 `json:"avg_cpu"`
|
|
MaxCPU float64 `json:"max_cpu"`
|
|
AvgMemory float64 `json:"avg_memory"`
|
|
MaxMemory float64 `json:"max_memory"`
|
|
Trend string `json:"trend"`
|
|
} `json:"summary"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
var avgCPU, maxCPU, avgMem, maxMem float64
|
|
var trend string
|
|
|
|
// Single-resource queries return Points array; compute summary from points
|
|
if len(resp.Points) > 0 {
|
|
var sumCPU, sumMem float64
|
|
for _, p := range resp.Points {
|
|
sumCPU += p.CPU
|
|
sumMem += p.Memory
|
|
if p.CPU > maxCPU {
|
|
maxCPU = p.CPU
|
|
}
|
|
if p.Memory > maxMem {
|
|
maxMem = p.Memory
|
|
}
|
|
}
|
|
n := float64(len(resp.Points))
|
|
avgCPU = sumCPU / n
|
|
avgMem = sumMem / n
|
|
} else if len(resp.Summary) > 0 {
|
|
// Multi-resource queries return Summary map
|
|
if s, ok := resp.Summary[resourceID]; ok {
|
|
avgCPU = s.AvgCPU
|
|
maxCPU = s.MaxCPU
|
|
avgMem = s.AvgMemory
|
|
maxMem = s.MaxMemory
|
|
trend = s.Trend
|
|
} else {
|
|
for _, s := range resp.Summary {
|
|
avgCPU = s.AvgCPU
|
|
maxCPU = s.MaxCPU
|
|
avgMem = s.AvgMemory
|
|
maxMem = s.MaxMemory
|
|
trend = s.Trend
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
var parts []string
|
|
if avgCPU > 0 {
|
|
parts = append(parts, fmt.Sprintf("avg_cpu=%.1f%%", avgCPU))
|
|
}
|
|
if maxCPU > 0 {
|
|
parts = append(parts, fmt.Sprintf("max_cpu=%.1f%%", maxCPU))
|
|
}
|
|
if avgMem > 0 {
|
|
parts = append(parts, fmt.Sprintf("avg_mem=%.1f%%", avgMem))
|
|
}
|
|
if maxMem > 0 {
|
|
parts = append(parts, fmt.Sprintf("max_mem=%.1f%%", maxMem))
|
|
}
|
|
if trend != "" {
|
|
parts = append(parts, "trend="+trend)
|
|
}
|
|
|
|
if len(parts) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return []FactEntry{{
|
|
Category: FactCategoryMetrics,
|
|
Key: fmt.Sprintf("metrics:%s", resourceID),
|
|
Value: truncateValue(strings.Join(parts, ", ")),
|
|
}}
|
|
}
|
|
|
|
func extractMetricsBaselinesFacts(resultText string) []FactEntry {
|
|
// BaselinesResponse — real format is nested: baselines.{nodeName}.{resourceKey:metricType}
|
|
// where each metric entry has mean/std_dev/min/max.
|
|
// Example: baselines.delly."delly:101:cpu" = {mean: 0.9, std_dev: 0.5, min: -0.2, max: 2.1}
|
|
// Node-level metrics use just "cpu"/"memory" as keys.
|
|
var resp struct {
|
|
Baselines map[string]map[string]struct {
|
|
Mean float64 `json:"mean"`
|
|
StdDev float64 `json:"std_dev"`
|
|
Min float64 `json:"min"`
|
|
Max float64 `json:"max"`
|
|
} `json:"baselines"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
// Emit marker fact so PredictFactKeys can gate repeat calls (even for empty results)
|
|
markerFact := FactEntry{
|
|
Category: FactCategoryMetrics,
|
|
Key: "baselines:queried",
|
|
Value: fmt.Sprintf("%d nodes extracted", len(resp.Baselines)),
|
|
}
|
|
|
|
if len(resp.Baselines) == 0 {
|
|
return []FactEntry{markerFact}
|
|
}
|
|
|
|
// Aggregate per-node: collect cpu and memory stats for each node
|
|
facts := []FactEntry{markerFact}
|
|
count := 0
|
|
for nodeName, metrics := range resp.Baselines {
|
|
if count >= 10 {
|
|
break
|
|
}
|
|
|
|
// Separate node-level metrics from resource-level metrics
|
|
var cpuMeans, memMeans []float64
|
|
var cpuMax, memMax float64
|
|
for metricKey, stat := range metrics {
|
|
// Keys are like "delly:101:cpu", "delly:101:memory", or bare "cpu", "memory"
|
|
if strings.HasSuffix(metricKey, ":cpu") || metricKey == "cpu" {
|
|
cpuMeans = append(cpuMeans, stat.Mean)
|
|
if stat.Max > cpuMax {
|
|
cpuMax = stat.Max
|
|
}
|
|
}
|
|
if strings.HasSuffix(metricKey, ":memory") || metricKey == "memory" {
|
|
memMeans = append(memMeans, stat.Mean)
|
|
if stat.Max > memMax {
|
|
memMax = stat.Max
|
|
}
|
|
}
|
|
}
|
|
|
|
var parts []string
|
|
if len(cpuMeans) > 0 {
|
|
avgCPU := average(cpuMeans)
|
|
parts = append(parts, fmt.Sprintf("cpu: avg=%.1f%% max=%.1f%%", avgCPU, cpuMax))
|
|
}
|
|
if len(memMeans) > 0 {
|
|
avgMem := average(memMeans)
|
|
parts = append(parts, fmt.Sprintf("memory: avg=%.1f%% max=%.1f%%", avgMem, memMax))
|
|
}
|
|
if len(parts) == 0 {
|
|
parts = append(parts, fmt.Sprintf("%d metrics tracked", len(metrics)))
|
|
}
|
|
|
|
facts = append(facts, FactEntry{
|
|
Category: FactCategoryMetrics,
|
|
Key: fmt.Sprintf("baseline:%s", nodeName),
|
|
Value: truncateValue(strings.Join(parts, ", ")),
|
|
})
|
|
count++
|
|
}
|
|
return facts
|
|
}
|
|
|
|
func average(vals []float64) float64 {
|
|
if len(vals) == 0 {
|
|
return 0
|
|
}
|
|
sum := 0.0
|
|
for _, v := range vals {
|
|
sum += v
|
|
}
|
|
return sum / float64(len(vals))
|
|
}
|
|
|
|
func extractMetricsDisksFacts(resultText string) []FactEntry {
|
|
// PhysicalDisksResponse
|
|
var resp struct {
|
|
Disks []struct {
|
|
Host string `json:"host"`
|
|
Device string `json:"device"`
|
|
Model string `json:"model"`
|
|
Health string `json:"health"`
|
|
Status string `json:"status"`
|
|
} `json:"disks"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
// Emit marker fact so PredictFactKeys can gate repeat calls (even for empty results)
|
|
markerFact := FactEntry{
|
|
Category: FactCategoryStorage,
|
|
Key: "physical_disks:queried",
|
|
Value: "summary extracted",
|
|
}
|
|
|
|
if len(resp.Disks) == 0 {
|
|
return []FactEntry{markerFact}
|
|
}
|
|
|
|
total := len(resp.Disks)
|
|
failed := 0
|
|
var failedDescs []string
|
|
for _, d := range resp.Disks {
|
|
health := strings.ToUpper(d.Health)
|
|
if health == "" {
|
|
health = strings.ToUpper(d.Status)
|
|
}
|
|
if health != "PASSED" && health != "OK" && health != "UNKNOWN" && health != "" {
|
|
failed++
|
|
desc := d.Host + " " + d.Device
|
|
if d.Model != "" {
|
|
desc += " " + d.Model
|
|
}
|
|
failedDescs = append(failedDescs, desc)
|
|
}
|
|
}
|
|
|
|
var value string
|
|
if failed == 0 {
|
|
value = fmt.Sprintf("%d disks total, all PASSED", total)
|
|
} else {
|
|
value = fmt.Sprintf("%d disks, %d FAILED: %s", total, failed, strings.Join(failedDescs, "; "))
|
|
}
|
|
|
|
return []FactEntry{markerFact, {
|
|
Category: FactCategoryStorage,
|
|
Key: "physical_disks:summary",
|
|
Value: truncateValue(value),
|
|
}}
|
|
}
|
|
|
|
// --- pulse_storage: disk_health ---
|
|
|
|
func extractDiskHealthFacts(resultText string) []FactEntry {
|
|
// DiskHealthResponse — per-host SMART data.
|
|
// Real format uses "smart" array, not "disks". Try both for robustness.
|
|
var resp struct {
|
|
Hosts []struct {
|
|
Hostname string `json:"hostname"`
|
|
Host string `json:"host"`
|
|
Smart []struct {
|
|
Device string `json:"device"`
|
|
Model string `json:"model"`
|
|
Health string `json:"health"`
|
|
Status string `json:"status"`
|
|
} `json:"smart"`
|
|
Disks []struct {
|
|
Device string `json:"device"`
|
|
Model string `json:"model"`
|
|
Health string `json:"health"`
|
|
Status string `json:"status"`
|
|
} `json:"disks"`
|
|
} `json:"hosts"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
// Emit marker fact so PredictFactKeys can gate repeat calls (even for empty results)
|
|
markerFact := FactEntry{
|
|
Category: FactCategoryStorage,
|
|
Key: "disk_health:queried",
|
|
Value: fmt.Sprintf("%d hosts extracted", len(resp.Hosts)),
|
|
}
|
|
|
|
if len(resp.Hosts) == 0 {
|
|
return []FactEntry{markerFact}
|
|
}
|
|
|
|
facts := []FactEntry{markerFact}
|
|
|
|
for _, host := range resp.Hosts {
|
|
hostname := host.Hostname
|
|
if hostname == "" {
|
|
hostname = host.Host
|
|
}
|
|
if hostname == "" {
|
|
continue
|
|
}
|
|
|
|
// Prefer "smart" field (real format), fall back to "disks" (test compat)
|
|
disks := host.Smart
|
|
if len(disks) == 0 {
|
|
disks = host.Disks
|
|
}
|
|
total := len(disks)
|
|
passed := 0
|
|
failed := 0
|
|
var failedDescs []string
|
|
for _, d := range disks {
|
|
health := strings.ToUpper(d.Health)
|
|
if health == "" {
|
|
health = strings.ToUpper(d.Status)
|
|
}
|
|
if health == "PASSED" || health == "OK" || health == "UNKNOWN" {
|
|
passed++
|
|
} else if health != "" {
|
|
failed++
|
|
desc := d.Device
|
|
if d.Model != "" {
|
|
desc += " " + d.Model
|
|
}
|
|
failedDescs = append(failedDescs, desc)
|
|
} else {
|
|
passed++ // Unknown treated as passed
|
|
}
|
|
}
|
|
|
|
var value string
|
|
if failed == 0 {
|
|
value = fmt.Sprintf("%d disks all PASSED", total)
|
|
} else {
|
|
value = fmt.Sprintf("%d SMART disks: %d PASSED, %d FAILED (%s)", total, passed, failed, strings.Join(failedDescs, ", "))
|
|
}
|
|
|
|
facts = append(facts, FactEntry{
|
|
Category: FactCategoryStorage,
|
|
Key: fmt.Sprintf("disk_health:%s", hostname),
|
|
Value: truncateValue(value),
|
|
})
|
|
}
|
|
return facts
|
|
}
|
|
|
|
// --- pulse_alerts ---
|
|
|
|
func extractAlertsFacts(input map[string]interface{}, resultText string) []FactEntry {
|
|
action := strFromMap(input, "action")
|
|
if action == "" {
|
|
action = strFromMap(input, "type")
|
|
}
|
|
|
|
switch action {
|
|
case "findings":
|
|
return extractAlertsFindingsFacts(resultText)
|
|
case "list":
|
|
return extractAlertsListFacts(resultText)
|
|
default:
|
|
log.Debug().Str("tool", "pulse_alerts").Str("action", action).
|
|
Msg("[KnowledgeExtractor] No extractor for action")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func extractAlertsFindingsFacts(resultText string) []FactEntry {
|
|
// Response format: {"active": [...], "counts": {"active": N, "dismissed": N}}
|
|
var resp struct {
|
|
Active []struct {
|
|
Key string `json:"key"`
|
|
Severity string `json:"severity"`
|
|
Title string `json:"title"`
|
|
ResourceID string `json:"resource_id"`
|
|
} `json:"active"`
|
|
Counts struct {
|
|
Active int `json:"active"`
|
|
Dismissed int `json:"dismissed"`
|
|
} `json:"counts"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
active := resp.Counts.Active
|
|
dismissed := resp.Counts.Dismissed
|
|
if active == 0 && dismissed == 0 && len(resp.Active) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var facts []FactEntry
|
|
facts = append(facts, FactEntry{
|
|
Category: FactCategoryFinding,
|
|
Key: "findings:overview",
|
|
Value: fmt.Sprintf("%d active, %d dismissed", active, dismissed),
|
|
})
|
|
|
|
// Per-finding facts (cap at 5)
|
|
limit := 5
|
|
if len(resp.Active) < limit {
|
|
limit = len(resp.Active)
|
|
}
|
|
for _, f := range resp.Active[:limit] {
|
|
if f.Key == "" {
|
|
continue
|
|
}
|
|
var parts []string
|
|
if f.Severity != "" {
|
|
parts = append(parts, f.Severity)
|
|
}
|
|
if f.Title != "" {
|
|
parts = append(parts, f.Title)
|
|
}
|
|
if f.ResourceID != "" {
|
|
parts = append(parts, "(resource="+f.ResourceID+")")
|
|
}
|
|
if len(parts) > 0 {
|
|
// Include resource_id in key to avoid upsert collisions when multiple
|
|
// findings share the same key (e.g. two "backup-failed" for different resources)
|
|
factKey := fmt.Sprintf("finding:%s", f.Key)
|
|
if f.ResourceID != "" {
|
|
factKey = fmt.Sprintf("finding:%s:%s", f.Key, f.ResourceID)
|
|
}
|
|
facts = append(facts, FactEntry{
|
|
Category: FactCategoryFinding,
|
|
Key: factKey,
|
|
Value: truncateValue(strings.Join(parts, ": ")),
|
|
})
|
|
}
|
|
}
|
|
|
|
return facts
|
|
}
|
|
|
|
func extractAlertsListFacts(resultText string) []FactEntry {
|
|
var resp struct {
|
|
Alerts []struct {
|
|
ResourceName string `json:"resource_name"`
|
|
Type string `json:"type"`
|
|
Severity string `json:"severity"`
|
|
Value float64 `json:"value"`
|
|
Threshold float64 `json:"threshold"`
|
|
Status string `json:"status"`
|
|
} `json:"alerts"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
if len(resp.Alerts) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Count active
|
|
active := 0
|
|
for _, a := range resp.Alerts {
|
|
if strings.ToLower(a.Status) != "resolved" {
|
|
active++
|
|
}
|
|
}
|
|
|
|
var facts []FactEntry
|
|
facts = append(facts, FactEntry{
|
|
Category: FactCategoryAlert,
|
|
Key: "alerts:overview",
|
|
Value: fmt.Sprintf("%d active alerts", active),
|
|
})
|
|
|
|
// Per-alert facts (cap at 5)
|
|
limit := 5
|
|
if len(resp.Alerts) < limit {
|
|
limit = len(resp.Alerts)
|
|
}
|
|
for _, a := range resp.Alerts[:limit] {
|
|
if a.ResourceName == "" && a.Type == "" {
|
|
continue
|
|
}
|
|
var parts []string
|
|
if a.Severity != "" {
|
|
parts = append(parts, a.Severity)
|
|
}
|
|
if a.Value > 0 && a.Threshold > 0 {
|
|
parts = append(parts, fmt.Sprintf("%.1f%% (threshold %.0f%%)", a.Value, a.Threshold))
|
|
}
|
|
key := fmt.Sprintf("alert:%s:%s", a.ResourceName, a.Type)
|
|
if len(parts) > 0 {
|
|
facts = append(facts, FactEntry{
|
|
Category: FactCategoryAlert,
|
|
Key: key,
|
|
Value: truncateValue(strings.Join(parts, ": ")),
|
|
})
|
|
}
|
|
}
|
|
|
|
return facts
|
|
}
|
|
|
|
// --- patrol_report_finding ---
|
|
|
|
func extractFindingFacts(input map[string]interface{}, resultText string) []FactEntry {
|
|
key := strFromMap(input, "key")
|
|
if key == "" {
|
|
key = strFromMap(input, "finding_key")
|
|
}
|
|
severity := strFromMap(input, "severity")
|
|
title := strFromMap(input, "title")
|
|
resourceID := strFromMap(input, "resource_id")
|
|
|
|
if key == "" || title == "" {
|
|
return nil
|
|
}
|
|
|
|
var parts []string
|
|
if severity != "" {
|
|
parts = append(parts, severity)
|
|
}
|
|
parts = append(parts, title)
|
|
if resourceID != "" {
|
|
parts = append(parts, "on "+resourceID)
|
|
}
|
|
|
|
return []FactEntry{{
|
|
Category: FactCategoryFinding,
|
|
Key: fmt.Sprintf("finding:%s", key),
|
|
Value: truncateValue(strings.Join(parts, ": ")),
|
|
}}
|
|
}
|
|
|
|
// --- pulse_docker ---
|
|
|
|
func extractDockerFacts(input map[string]interface{}, resultText string) []FactEntry {
|
|
action := strFromMap(input, "action")
|
|
if action == "" {
|
|
action = strFromMap(input, "type")
|
|
}
|
|
|
|
switch action {
|
|
case "services":
|
|
return extractDockerServicesFacts(resultText)
|
|
case "updates":
|
|
return extractDockerUpdatesFacts(resultText)
|
|
case "swarm":
|
|
return extractDockerSwarmFacts(resultText)
|
|
case "tasks":
|
|
return extractDockerTasksFacts(resultText)
|
|
default:
|
|
log.Debug().Str("tool", "pulse_docker").Str("action", action).
|
|
Msg("[KnowledgeExtractor] No extractor for action")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func extractDockerServicesFacts(resultText string) []FactEntry {
|
|
var resp struct {
|
|
Host string `json:"host"`
|
|
Services []struct {
|
|
Name string `json:"name"`
|
|
Mode string `json:"mode"`
|
|
DesiredTasks int `json:"desired_tasks"`
|
|
RunningTasks int `json:"running_tasks"`
|
|
UpdateStatus string `json:"update_status"`
|
|
} `json:"services"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
total := resp.Total
|
|
if total == 0 {
|
|
total = len(resp.Services)
|
|
}
|
|
|
|
markerFact := FactEntry{
|
|
Category: FactCategoryResource,
|
|
Key: "docker_services:queried",
|
|
Value: fmt.Sprintf("%d services", total),
|
|
}
|
|
|
|
if total == 0 {
|
|
return []FactEntry{markerFact}
|
|
}
|
|
|
|
// Count healthy vs unhealthy
|
|
healthy := 0
|
|
for _, s := range resp.Services {
|
|
if s.RunningTasks >= s.DesiredTasks {
|
|
healthy++
|
|
}
|
|
}
|
|
|
|
summaryValue := fmt.Sprintf("%d services, %d healthy, %d degraded", total, healthy, total-healthy)
|
|
|
|
return []FactEntry{markerFact, {
|
|
Category: FactCategoryResource,
|
|
Key: "docker_services:summary",
|
|
Value: truncateValue(summaryValue),
|
|
}}
|
|
}
|
|
|
|
func extractDockerUpdatesFacts(resultText string) []FactEntry {
|
|
var resp struct {
|
|
Updates []struct {
|
|
ContainerName string `json:"container_name"`
|
|
UpdateAvailable bool `json:"update_available"`
|
|
Error string `json:"error"`
|
|
} `json:"updates"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
total := resp.Total
|
|
if total == 0 {
|
|
total = len(resp.Updates)
|
|
}
|
|
|
|
available := 0
|
|
for _, u := range resp.Updates {
|
|
if u.UpdateAvailable {
|
|
available++
|
|
}
|
|
}
|
|
|
|
markerFact := FactEntry{
|
|
Category: FactCategoryResource,
|
|
Key: "docker_updates:queried",
|
|
Value: fmt.Sprintf("%d containers, %d updates available", total, available),
|
|
}
|
|
|
|
return []FactEntry{markerFact}
|
|
}
|
|
|
|
func extractDockerSwarmFacts(resultText string) []FactEntry {
|
|
var resp struct {
|
|
Host string `json:"host"`
|
|
Status struct {
|
|
NodeRole string `json:"node_role"`
|
|
LocalState string `json:"local_state"`
|
|
ControlAvailable bool `json:"control_available"`
|
|
ClusterName string `json:"cluster_name"`
|
|
Error string `json:"error"`
|
|
} `json:"status"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
host := resp.Host
|
|
s := resp.Status
|
|
|
|
var parts []string
|
|
if s.NodeRole != "" {
|
|
parts = append(parts, "role="+s.NodeRole)
|
|
}
|
|
if s.LocalState != "" {
|
|
parts = append(parts, "state="+s.LocalState)
|
|
}
|
|
if s.ControlAvailable {
|
|
parts = append(parts, "manager")
|
|
}
|
|
if s.Error != "" {
|
|
parts = append(parts, "error="+s.Error)
|
|
}
|
|
|
|
if len(parts) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if host != "" {
|
|
parts = append(parts, "host="+host)
|
|
}
|
|
|
|
return []FactEntry{{
|
|
Category: FactCategoryResource,
|
|
Key: "docker_swarm:status",
|
|
Value: truncateValue(strings.Join(parts, ", ")),
|
|
}}
|
|
}
|
|
|
|
func extractDockerTasksFacts(resultText string) []FactEntry {
|
|
var resp struct {
|
|
Host string `json:"host"`
|
|
Service string `json:"service"`
|
|
Tasks []struct {
|
|
CurrentState string `json:"current_state"`
|
|
Error string `json:"error"`
|
|
} `json:"tasks"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
total := resp.Total
|
|
if total == 0 {
|
|
total = len(resp.Tasks)
|
|
}
|
|
|
|
running := 0
|
|
failed := 0
|
|
for _, t := range resp.Tasks {
|
|
switch strings.ToLower(t.CurrentState) {
|
|
case "running":
|
|
running++
|
|
case "failed", "rejected":
|
|
failed++
|
|
}
|
|
}
|
|
|
|
value := fmt.Sprintf("%d tasks, %d running, %d failed", total, running, failed)
|
|
if resp.Service != "" {
|
|
value = fmt.Sprintf("service=%s, %s", resp.Service, value)
|
|
}
|
|
|
|
return []FactEntry{{
|
|
Category: FactCategoryResource,
|
|
Key: "docker_tasks:queried",
|
|
Value: truncateValue(value),
|
|
}}
|
|
}
|
|
|
|
// --- pulse_kubernetes ---
|
|
|
|
func extractKubernetesFacts(input map[string]interface{}, resultText string) []FactEntry {
|
|
action := strFromMap(input, "action")
|
|
if action == "" {
|
|
action = strFromMap(input, "type")
|
|
}
|
|
|
|
switch action {
|
|
case "clusters":
|
|
return extractK8sClustersFacts(resultText)
|
|
case "nodes":
|
|
return extractK8sNodesFacts(resultText)
|
|
case "pods":
|
|
return extractK8sPodsFacts(resultText)
|
|
case "deployments":
|
|
return extractK8sDeploymentsFacts(resultText)
|
|
default:
|
|
log.Debug().Str("tool", "pulse_kubernetes").Str("action", action).
|
|
Msg("[KnowledgeExtractor] No extractor for action")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func extractK8sClustersFacts(resultText string) []FactEntry {
|
|
var resp struct {
|
|
Clusters []struct {
|
|
Name string `json:"name"`
|
|
DisplayName string `json:"display_name"`
|
|
Status string `json:"status"`
|
|
NodeCount int `json:"node_count"`
|
|
PodCount int `json:"pod_count"`
|
|
ReadyNodes int `json:"ready_nodes"`
|
|
} `json:"clusters"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
total := resp.Total
|
|
if total == 0 {
|
|
total = len(resp.Clusters)
|
|
}
|
|
|
|
markerFact := FactEntry{
|
|
Category: FactCategoryResource,
|
|
Key: "k8s_clusters:queried",
|
|
Value: fmt.Sprintf("%d clusters", total),
|
|
}
|
|
|
|
if total == 0 {
|
|
return []FactEntry{markerFact}
|
|
}
|
|
|
|
facts := []FactEntry{markerFact}
|
|
limit := 5
|
|
if len(resp.Clusters) < limit {
|
|
limit = len(resp.Clusters)
|
|
}
|
|
for _, c := range resp.Clusters[:limit] {
|
|
name := c.DisplayName
|
|
if name == "" {
|
|
name = c.Name
|
|
}
|
|
if name == "" {
|
|
continue
|
|
}
|
|
value := fmt.Sprintf("%s, %d nodes (%d ready), %d pods",
|
|
c.Status, c.NodeCount, c.ReadyNodes, c.PodCount)
|
|
facts = append(facts, FactEntry{
|
|
Category: FactCategoryResource,
|
|
Key: fmt.Sprintf("k8s_cluster:%s", name),
|
|
Value: truncateValue(value),
|
|
})
|
|
}
|
|
return facts
|
|
}
|
|
|
|
func extractK8sNodesFacts(resultText string) []FactEntry {
|
|
var resp struct {
|
|
Cluster string `json:"cluster"`
|
|
Nodes []struct {
|
|
Name string `json:"name"`
|
|
Ready bool `json:"ready"`
|
|
Roles []string `json:"roles"`
|
|
} `json:"nodes"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
total := resp.Total
|
|
if total == 0 {
|
|
total = len(resp.Nodes)
|
|
}
|
|
|
|
ready := 0
|
|
for _, n := range resp.Nodes {
|
|
if n.Ready {
|
|
ready++
|
|
}
|
|
}
|
|
|
|
value := fmt.Sprintf("%d nodes, %d ready, %d not ready", total, ready, total-ready)
|
|
if resp.Cluster != "" {
|
|
value = fmt.Sprintf("cluster=%s, %s", resp.Cluster, value)
|
|
}
|
|
|
|
return []FactEntry{{
|
|
Category: FactCategoryResource,
|
|
Key: "k8s_nodes:queried",
|
|
Value: truncateValue(value),
|
|
}}
|
|
}
|
|
|
|
func extractK8sPodsFacts(resultText string) []FactEntry {
|
|
var resp struct {
|
|
Cluster string `json:"cluster"`
|
|
Pods []struct {
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
Phase string `json:"phase"`
|
|
Restarts int `json:"restarts"`
|
|
} `json:"pods"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
total := resp.Total
|
|
if total == 0 {
|
|
total = len(resp.Pods)
|
|
}
|
|
|
|
// Count by phase
|
|
phaseCounts := make(map[string]int)
|
|
crashLoop := 0
|
|
for _, p := range resp.Pods {
|
|
phase := p.Phase
|
|
if phase == "" {
|
|
phase = "Unknown"
|
|
}
|
|
phaseCounts[phase]++
|
|
if p.Restarts > 5 {
|
|
crashLoop++
|
|
}
|
|
}
|
|
|
|
var parts []string
|
|
if resp.Cluster != "" {
|
|
parts = append(parts, "cluster="+resp.Cluster)
|
|
}
|
|
parts = append(parts, fmt.Sprintf("%d pods", total))
|
|
for phase, count := range phaseCounts {
|
|
parts = append(parts, fmt.Sprintf("%d %s", count, phase))
|
|
}
|
|
if crashLoop > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d high-restart", crashLoop))
|
|
}
|
|
|
|
return []FactEntry{{
|
|
Category: FactCategoryResource,
|
|
Key: "k8s_pods:queried",
|
|
Value: truncateValue(strings.Join(parts, ", ")),
|
|
}}
|
|
}
|
|
|
|
func extractK8sDeploymentsFacts(resultText string) []FactEntry {
|
|
var resp struct {
|
|
Cluster string `json:"cluster"`
|
|
Deployments []struct {
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
DesiredReplicas int32 `json:"desired_replicas"`
|
|
ReadyReplicas int32 `json:"ready_replicas"`
|
|
AvailableReplicas int32 `json:"available_replicas"`
|
|
} `json:"deployments"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
total := resp.Total
|
|
if total == 0 {
|
|
total = len(resp.Deployments)
|
|
}
|
|
|
|
healthy := 0
|
|
degraded := 0
|
|
for _, d := range resp.Deployments {
|
|
if d.ReadyReplicas >= d.DesiredReplicas {
|
|
healthy++
|
|
} else {
|
|
degraded++
|
|
}
|
|
}
|
|
|
|
value := fmt.Sprintf("%d deployments, %d healthy, %d degraded", total, healthy, degraded)
|
|
if resp.Cluster != "" {
|
|
value = fmt.Sprintf("cluster=%s, %s", resp.Cluster, value)
|
|
}
|
|
|
|
return []FactEntry{{
|
|
Category: FactCategoryResource,
|
|
Key: "k8s_deployments:queried",
|
|
Value: truncateValue(value),
|
|
}}
|
|
}
|
|
|
|
// --- pulse_pmg ---
|
|
|
|
func extractPMGFacts(input map[string]interface{}, resultText string) []FactEntry {
|
|
action := strFromMap(input, "action")
|
|
if action == "" {
|
|
action = strFromMap(input, "type")
|
|
}
|
|
|
|
switch action {
|
|
case "status":
|
|
return extractPMGStatusFacts(resultText)
|
|
case "mail_stats":
|
|
return extractPMGMailStatsFacts(resultText)
|
|
case "queues":
|
|
return extractPMGQueuesFacts(resultText)
|
|
case "spam":
|
|
return extractPMGSpamFacts(resultText)
|
|
default:
|
|
log.Debug().Str("tool", "pulse_pmg").Str("action", action).
|
|
Msg("[KnowledgeExtractor] No extractor for action")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func extractPMGStatusFacts(resultText string) []FactEntry {
|
|
var resp struct {
|
|
Instances []struct {
|
|
Name string `json:"name"`
|
|
Host string `json:"host"`
|
|
Status string `json:"status"`
|
|
Version string `json:"version"`
|
|
} `json:"instances"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
total := resp.Total
|
|
if total == 0 {
|
|
total = len(resp.Instances)
|
|
}
|
|
|
|
markerFact := FactEntry{
|
|
Category: FactCategoryResource,
|
|
Key: "pmg:queried",
|
|
Value: fmt.Sprintf("%d instances", total),
|
|
}
|
|
|
|
if total == 0 {
|
|
return []FactEntry{markerFact}
|
|
}
|
|
|
|
facts := []FactEntry{markerFact}
|
|
for _, inst := range resp.Instances {
|
|
name := inst.Name
|
|
if name == "" {
|
|
name = inst.Host
|
|
}
|
|
if name == "" {
|
|
continue
|
|
}
|
|
var parts []string
|
|
if inst.Status != "" {
|
|
parts = append(parts, inst.Status)
|
|
}
|
|
if inst.Version != "" {
|
|
parts = append(parts, "v"+inst.Version)
|
|
}
|
|
if len(parts) == 0 {
|
|
continue
|
|
}
|
|
facts = append(facts, FactEntry{
|
|
Category: FactCategoryResource,
|
|
Key: fmt.Sprintf("pmg:%s", name),
|
|
Value: truncateValue(strings.Join(parts, ", ")),
|
|
})
|
|
}
|
|
return facts
|
|
}
|
|
|
|
func extractPMGMailStatsFacts(resultText string) []FactEntry {
|
|
var resp struct {
|
|
Instance string `json:"instance"`
|
|
Stats struct {
|
|
TotalIn float64 `json:"total_in"`
|
|
TotalOut float64 `json:"total_out"`
|
|
SpamIn float64 `json:"spam_in"`
|
|
VirusIn float64 `json:"virus_in"`
|
|
} `json:"stats"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
s := resp.Stats
|
|
value := fmt.Sprintf("in=%.0f out=%.0f spam=%.0f virus=%.0f",
|
|
s.TotalIn, s.TotalOut, s.SpamIn, s.VirusIn)
|
|
if resp.Instance != "" {
|
|
value = resp.Instance + ": " + value
|
|
}
|
|
|
|
return []FactEntry{{
|
|
Category: FactCategoryResource,
|
|
Key: "pmg_mail_stats:queried",
|
|
Value: truncateValue(value),
|
|
}}
|
|
}
|
|
|
|
func extractPMGQueuesFacts(resultText string) []FactEntry {
|
|
var resp struct {
|
|
Instance string `json:"instance"`
|
|
Queues []struct {
|
|
Node string `json:"node"`
|
|
Active int `json:"active"`
|
|
Deferred int `json:"deferred"`
|
|
Total int `json:"total"`
|
|
} `json:"queues"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
totalQueued := 0
|
|
totalDeferred := 0
|
|
for _, q := range resp.Queues {
|
|
totalQueued += q.Total
|
|
totalDeferred += q.Deferred
|
|
}
|
|
|
|
value := fmt.Sprintf("%d nodes, %d queued, %d deferred",
|
|
len(resp.Queues), totalQueued, totalDeferred)
|
|
if resp.Instance != "" {
|
|
value = resp.Instance + ": " + value
|
|
}
|
|
|
|
return []FactEntry{{
|
|
Category: FactCategoryResource,
|
|
Key: "pmg_queues:queried",
|
|
Value: truncateValue(value),
|
|
}}
|
|
}
|
|
|
|
func extractPMGSpamFacts(resultText string) []FactEntry {
|
|
var resp struct {
|
|
Instance string `json:"instance"`
|
|
Quarantine struct {
|
|
Spam int `json:"spam"`
|
|
Virus int `json:"virus"`
|
|
Total int `json:"total"`
|
|
} `json:"quarantine"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
q := resp.Quarantine
|
|
value := fmt.Sprintf("quarantine: %d total (%d spam, %d virus)", q.Total, q.Spam, q.Virus)
|
|
if resp.Instance != "" {
|
|
value = resp.Instance + ": " + value
|
|
}
|
|
|
|
return []FactEntry{{
|
|
Category: FactCategoryResource,
|
|
Key: "pmg_spam:queried",
|
|
Value: truncateValue(value),
|
|
}}
|
|
}
|
|
|
|
// --- patrol_get_findings ---
|
|
|
|
func extractPatrolGetFindingsFacts(resultText string) []FactEntry {
|
|
var resp struct {
|
|
OK bool `json:"ok"`
|
|
Count int `json:"count"`
|
|
Findings []struct {
|
|
Key string `json:"key"`
|
|
Severity string `json:"severity"`
|
|
Title string `json:"title"`
|
|
ResourceID string `json:"resource_id"`
|
|
} `json:"findings"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
total := resp.Count
|
|
if total == 0 {
|
|
total = len(resp.Findings)
|
|
}
|
|
|
|
markerFact := FactEntry{
|
|
Category: FactCategoryFinding,
|
|
Key: "patrol_findings:queried",
|
|
Value: fmt.Sprintf("%d findings", total),
|
|
}
|
|
|
|
if total == 0 {
|
|
return []FactEntry{markerFact}
|
|
}
|
|
|
|
// Count by severity
|
|
severityCounts := make(map[string]int)
|
|
for _, f := range resp.Findings {
|
|
sev := f.Severity
|
|
if sev == "" {
|
|
sev = "unknown"
|
|
}
|
|
severityCounts[sev]++
|
|
}
|
|
|
|
var parts []string
|
|
parts = append(parts, fmt.Sprintf("%d total", total))
|
|
for sev, count := range severityCounts {
|
|
parts = append(parts, fmt.Sprintf("%d %s", count, sev))
|
|
}
|
|
|
|
facts := []FactEntry{markerFact, {
|
|
Category: FactCategoryFinding,
|
|
Key: "patrol_findings:summary",
|
|
Value: truncateValue(strings.Join(parts, ", ")),
|
|
}}
|
|
|
|
return facts
|
|
}
|
|
|
|
// PredictFactKeys returns the KA fact keys that this tool call would produce,
|
|
// based solely on the tool input (without needing the result).
|
|
// Used by the gate to check if we already have facts for this call.
|
|
// Returns nil if the key can't be predicted from input alone.
|
|
func PredictFactKeys(toolName string, toolInput map[string]interface{}) []string {
|
|
switch toolName {
|
|
case "pulse_query":
|
|
action := strFromMap(toolInput, "action")
|
|
if action == "" {
|
|
action = strFromMap(toolInput, "type")
|
|
}
|
|
switch action {
|
|
case "topology":
|
|
return []string{"topology:summary"}
|
|
case "health":
|
|
return []string{"health:connections"}
|
|
case "get":
|
|
resourceID := strFromMap(toolInput, "resource_id")
|
|
if resourceID != "" {
|
|
return []string{
|
|
fmt.Sprintf("query:get:%s:cached", resourceID),
|
|
fmt.Sprintf("query:get:%s:error", resourceID),
|
|
}
|
|
}
|
|
return nil
|
|
case "search":
|
|
query := strFromMap(toolInput, "query")
|
|
if query == "" {
|
|
query = strFromMap(toolInput, "search")
|
|
}
|
|
if query != "" {
|
|
return []string{fmt.Sprintf("search:%s:summary", query)}
|
|
}
|
|
case "list":
|
|
return []string{"inventory:summary"}
|
|
case "config":
|
|
resourceID := strFromMap(toolInput, "resource_id")
|
|
if resourceID != "" {
|
|
// Predict the cached key (always available) + node-specific key if node is provided
|
|
keys := []string{fmt.Sprintf("config:%s:cached", resourceID)}
|
|
node := strFromMap(toolInput, "node")
|
|
if node != "" {
|
|
keys = append(keys, fmt.Sprintf("config:%s:%s", node, resourceID))
|
|
}
|
|
return keys
|
|
}
|
|
}
|
|
case "pulse_discovery":
|
|
host := strFromMap(toolInput, "host_id")
|
|
if host == "" {
|
|
host = strFromMap(toolInput, "host")
|
|
}
|
|
id := strFromMap(toolInput, "resource_id")
|
|
if host != "" && id != "" {
|
|
return []string{fmt.Sprintf("discovery:%s:%s", host, id)}
|
|
}
|
|
case "pulse_read", "pulse_run_command":
|
|
host := strFromMap(toolInput, "target_host")
|
|
if host == "" {
|
|
host = strFromMap(toolInput, "host")
|
|
}
|
|
cmdPrefix := buildExecKeyCmd(toolInput)
|
|
if host != "" && cmdPrefix != "" {
|
|
return []string{fmt.Sprintf("exec:%s:%s", host, cmdPrefix)}
|
|
}
|
|
case "pulse_storage":
|
|
action := strFromMap(toolInput, "action")
|
|
if action == "" {
|
|
action = strFromMap(toolInput, "type")
|
|
}
|
|
switch action {
|
|
case "pools":
|
|
return []string{"storage:pools:queried"}
|
|
case "disk_health":
|
|
return []string{"disk_health:queried"}
|
|
case "raid":
|
|
return []string{"raid:queried"}
|
|
case "backups":
|
|
return []string{"backups:queried"}
|
|
case "ceph":
|
|
return []string{"ceph:queried"}
|
|
case "ceph_details":
|
|
return []string{"ceph_details:queried"}
|
|
case "snapshots":
|
|
return []string{"snapshots:queried"}
|
|
case "replication":
|
|
return []string{"replication:queried"}
|
|
case "pbs_jobs":
|
|
return []string{"pbs_jobs:queried"}
|
|
case "resource_disks":
|
|
return []string{"resource_disks:queried"}
|
|
case "backup_tasks":
|
|
return []string{"backup_tasks:queried"}
|
|
}
|
|
case "pulse_metrics":
|
|
action := strFromMap(toolInput, "action")
|
|
if action == "" {
|
|
action = strFromMap(toolInput, "type")
|
|
}
|
|
switch action {
|
|
case "performance":
|
|
resourceID := strFromMap(toolInput, "resource_id")
|
|
if resourceID != "" {
|
|
return []string{fmt.Sprintf("metrics:%s", resourceID)}
|
|
}
|
|
case "baselines":
|
|
// Always predict the marker key — the extractor emits baselines:queried
|
|
// regardless of whether resource_id is provided.
|
|
return []string{"baselines:queried"}
|
|
case "disks":
|
|
return []string{"physical_disks:queried"}
|
|
case "temperatures":
|
|
return []string{"temperatures:queried"}
|
|
}
|
|
case "pulse_alerts":
|
|
action := strFromMap(toolInput, "action")
|
|
if action == "" {
|
|
action = strFromMap(toolInput, "type")
|
|
}
|
|
switch action {
|
|
case "findings":
|
|
return []string{"findings:overview"}
|
|
case "list":
|
|
return []string{"alerts:overview"}
|
|
}
|
|
case "pulse_docker":
|
|
action := strFromMap(toolInput, "action")
|
|
if action == "" {
|
|
action = strFromMap(toolInput, "type")
|
|
}
|
|
switch action {
|
|
case "services":
|
|
return []string{"docker_services:queried", "docker_services:summary"}
|
|
case "updates":
|
|
return []string{"docker_updates:queried"}
|
|
case "swarm":
|
|
return []string{"docker_swarm:status"}
|
|
case "tasks":
|
|
return []string{"docker_tasks:queried"}
|
|
}
|
|
case "pulse_kubernetes":
|
|
action := strFromMap(toolInput, "action")
|
|
if action == "" {
|
|
action = strFromMap(toolInput, "type")
|
|
}
|
|
switch action {
|
|
case "clusters":
|
|
return []string{"k8s_clusters:queried"}
|
|
case "nodes":
|
|
return []string{"k8s_nodes:queried"}
|
|
case "pods":
|
|
return []string{"k8s_pods:queried"}
|
|
case "deployments":
|
|
return []string{"k8s_deployments:queried"}
|
|
}
|
|
case "pulse_pmg":
|
|
action := strFromMap(toolInput, "action")
|
|
if action == "" {
|
|
action = strFromMap(toolInput, "type")
|
|
}
|
|
switch action {
|
|
case "status":
|
|
return []string{"pmg:queried"}
|
|
case "mail_stats":
|
|
return []string{"pmg_mail_stats:queried"}
|
|
case "queues":
|
|
return []string{"pmg_queues:queried"}
|
|
case "spam":
|
|
return []string{"pmg_spam:queried"}
|
|
}
|
|
case "patrol_get_findings":
|
|
return []string{"patrol_findings:queried"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- pulse_metrics: temperatures ---
|
|
|
|
func extractMetricsTemperaturesFacts(resultText string) []FactEntry {
|
|
var hosts []struct {
|
|
Hostname string `json:"hostname"`
|
|
CPUTemps map[string]float64 `json:"cpu_temps"`
|
|
DiskTemps map[string]float64 `json:"disk_temps"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &hosts); err != nil {
|
|
return nil
|
|
}
|
|
|
|
markerFact := FactEntry{
|
|
Category: FactCategoryMetrics,
|
|
Key: "temperatures:queried",
|
|
Value: fmt.Sprintf("%d hosts", len(hosts)),
|
|
}
|
|
if len(hosts) == 0 {
|
|
return []FactEntry{markerFact}
|
|
}
|
|
|
|
facts := []FactEntry{markerFact}
|
|
for _, h := range hosts {
|
|
if h.Hostname == "" {
|
|
continue
|
|
}
|
|
var maxCPU float64
|
|
for _, t := range h.CPUTemps {
|
|
if t > maxCPU {
|
|
maxCPU = t
|
|
}
|
|
}
|
|
var maxDisk float64
|
|
for _, t := range h.DiskTemps {
|
|
if t > maxDisk {
|
|
maxDisk = t
|
|
}
|
|
}
|
|
var parts []string
|
|
if maxCPU > 0 {
|
|
parts = append(parts, fmt.Sprintf("cpu_max=%.0f°C", maxCPU))
|
|
}
|
|
if maxDisk > 0 {
|
|
parts = append(parts, fmt.Sprintf("disk_max=%.0f°C", maxDisk))
|
|
}
|
|
if len(parts) == 0 {
|
|
continue
|
|
}
|
|
facts = append(facts, FactEntry{
|
|
Category: FactCategoryMetrics,
|
|
Key: fmt.Sprintf("temperatures:%s", h.Hostname),
|
|
Value: truncateValue(strings.Join(parts, ", ")),
|
|
})
|
|
}
|
|
return facts
|
|
}
|
|
|
|
// --- pulse_storage: raid ---
|
|
|
|
func extractStorageRAIDFacts(resultText string) []FactEntry {
|
|
var resp struct {
|
|
Hosts []struct {
|
|
Hostname string `json:"hostname"`
|
|
Arrays []struct {
|
|
Device string `json:"device"`
|
|
Level string `json:"level"`
|
|
State string `json:"state"`
|
|
FailedDevices int `json:"failed_devices"`
|
|
TotalDevices int `json:"total_devices"`
|
|
} `json:"arrays"`
|
|
} `json:"hosts"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
markerFact := FactEntry{
|
|
Category: FactCategoryStorage,
|
|
Key: "raid:queried",
|
|
Value: fmt.Sprintf("%d hosts", len(resp.Hosts)),
|
|
}
|
|
if len(resp.Hosts) == 0 {
|
|
return []FactEntry{markerFact}
|
|
}
|
|
|
|
facts := []FactEntry{markerFact}
|
|
for _, h := range resp.Hosts {
|
|
if h.Hostname == "" {
|
|
continue
|
|
}
|
|
totalArrays := len(h.Arrays)
|
|
degraded := 0
|
|
for _, a := range h.Arrays {
|
|
if (a.State != "clean" && a.State != "active") || a.FailedDevices > 0 {
|
|
degraded++
|
|
}
|
|
}
|
|
var value string
|
|
if degraded == 0 {
|
|
value = fmt.Sprintf("%d arrays, all clean", totalArrays)
|
|
} else {
|
|
value = fmt.Sprintf("%d arrays, %d degraded/failed", totalArrays, degraded)
|
|
}
|
|
facts = append(facts, FactEntry{
|
|
Category: FactCategoryStorage,
|
|
Key: fmt.Sprintf("raid:%s", h.Hostname),
|
|
Value: truncateValue(value),
|
|
})
|
|
}
|
|
return facts
|
|
}
|
|
|
|
// --- pulse_storage: backups ---
|
|
|
|
func extractStorageBackupsFacts(resultText string) []FactEntry {
|
|
var resp struct {
|
|
PBS []json.RawMessage `json:"pbs"`
|
|
PVE []json.RawMessage `json:"pve"`
|
|
PBSServers []struct {
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Datastores []struct {
|
|
Name string `json:"name"`
|
|
UsagePercent float64 `json:"usage_percent"`
|
|
} `json:"datastores"`
|
|
} `json:"pbs_servers"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
totalBackups := len(resp.PBS) + len(resp.PVE)
|
|
markerFact := FactEntry{
|
|
Category: FactCategoryStorage,
|
|
Key: "backups:queried",
|
|
Value: fmt.Sprintf("%d PBS + %d PVE backups, %d PBS servers", len(resp.PBS), len(resp.PVE), len(resp.PBSServers)),
|
|
}
|
|
|
|
facts := []FactEntry{markerFact}
|
|
|
|
for _, srv := range resp.PBSServers {
|
|
if srv.Name == "" {
|
|
continue
|
|
}
|
|
var parts []string
|
|
parts = append(parts, srv.Status)
|
|
for _, ds := range srv.Datastores {
|
|
parts = append(parts, fmt.Sprintf("%s: %.1f%% used", ds.Name, ds.UsagePercent))
|
|
}
|
|
facts = append(facts, FactEntry{
|
|
Category: FactCategoryStorage,
|
|
Key: fmt.Sprintf("backups:server:%s", srv.Name),
|
|
Value: truncateValue(strings.Join(parts, ", ")),
|
|
})
|
|
}
|
|
|
|
if totalBackups > 0 {
|
|
facts = append(facts, FactEntry{
|
|
Category: FactCategoryStorage,
|
|
Key: "backups:summary",
|
|
Value: fmt.Sprintf("%d PBS backups, %d PVE backups", len(resp.PBS), len(resp.PVE)),
|
|
})
|
|
}
|
|
|
|
return facts
|
|
}
|
|
|
|
// --- pulse_storage: ceph ---
|
|
|
|
func extractCephFacts(resultText string) []FactEntry {
|
|
var clusters []struct {
|
|
Name string `json:"name"`
|
|
Health string `json:"health"`
|
|
Details struct {
|
|
OSDCount int `json:"osd_count"`
|
|
OSDsUp int `json:"osds_up"`
|
|
OSDsDown int `json:"osds_down"`
|
|
Monitors int `json:"monitors"`
|
|
UsagePercent float64 `json:"usage_percent"`
|
|
} `json:"details"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &clusters); err != nil {
|
|
return nil
|
|
}
|
|
|
|
markerFact := FactEntry{
|
|
Category: FactCategoryStorage,
|
|
Key: "ceph:queried",
|
|
Value: fmt.Sprintf("%d clusters", len(clusters)),
|
|
}
|
|
|
|
if len(clusters) == 0 {
|
|
return []FactEntry{markerFact}
|
|
}
|
|
|
|
facts := []FactEntry{markerFact}
|
|
limit := 5
|
|
if len(clusters) < limit {
|
|
limit = len(clusters)
|
|
}
|
|
for _, c := range clusters[:limit] {
|
|
if c.Name == "" {
|
|
continue
|
|
}
|
|
d := c.Details
|
|
value := fmt.Sprintf("%s, %d OSDs (%d up, %d down), %d monitors, %.0f%% used",
|
|
c.Health, d.OSDCount, d.OSDsUp, d.OSDsDown, d.Monitors, d.UsagePercent)
|
|
facts = append(facts, FactEntry{
|
|
Category: FactCategoryStorage,
|
|
Key: fmt.Sprintf("ceph:%s", c.Name),
|
|
Value: truncateValue(value),
|
|
})
|
|
}
|
|
return facts
|
|
}
|
|
|
|
// --- pulse_storage: ceph_details ---
|
|
|
|
func extractCephDetailsFacts(resultText string) []FactEntry {
|
|
var resp struct {
|
|
Hosts []struct {
|
|
Hostname string `json:"hostname"`
|
|
Health struct {
|
|
Status string `json:"status"`
|
|
} `json:"health"`
|
|
OSDMap struct {
|
|
NumOSDs int `json:"num_osds"`
|
|
NumUp int `json:"num_up"`
|
|
NumDown int `json:"num_down"`
|
|
} `json:"osd_map"`
|
|
PGMap struct {
|
|
UsagePercent float64 `json:"usage_percent"`
|
|
} `json:"pg_map"`
|
|
Pools []json.RawMessage `json:"pools"`
|
|
} `json:"hosts"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
markerFact := FactEntry{
|
|
Category: FactCategoryStorage,
|
|
Key: "ceph_details:queried",
|
|
Value: fmt.Sprintf("%d hosts", len(resp.Hosts)),
|
|
}
|
|
|
|
if len(resp.Hosts) == 0 {
|
|
return []FactEntry{markerFact}
|
|
}
|
|
|
|
facts := []FactEntry{markerFact}
|
|
limit := 5
|
|
if len(resp.Hosts) < limit {
|
|
limit = len(resp.Hosts)
|
|
}
|
|
for _, h := range resp.Hosts[:limit] {
|
|
if h.Hostname == "" {
|
|
continue
|
|
}
|
|
value := fmt.Sprintf("%s, %d OSDs (%d up), %.0f%% used, %d pools",
|
|
h.Health.Status, h.OSDMap.NumOSDs, h.OSDMap.NumUp, h.PGMap.UsagePercent, len(h.Pools))
|
|
facts = append(facts, FactEntry{
|
|
Category: FactCategoryStorage,
|
|
Key: fmt.Sprintf("ceph_details:%s", h.Hostname),
|
|
Value: truncateValue(value),
|
|
})
|
|
}
|
|
return facts
|
|
}
|
|
|
|
// --- pulse_storage: snapshots ---
|
|
|
|
func extractSnapshotsFacts(resultText string) []FactEntry {
|
|
var resp struct {
|
|
Snapshots []struct {
|
|
VMID int `json:"vmid"`
|
|
VMName string `json:"vm_name"`
|
|
Type string `json:"type"`
|
|
Node string `json:"node"`
|
|
SnapshotName string `json:"snapshot_name"`
|
|
} `json:"snapshots"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
total := resp.Total
|
|
if total == 0 {
|
|
total = len(resp.Snapshots)
|
|
}
|
|
|
|
markerFact := FactEntry{
|
|
Category: FactCategoryStorage,
|
|
Key: "snapshots:queried",
|
|
Value: fmt.Sprintf("%d snapshots", total),
|
|
}
|
|
|
|
if total == 0 {
|
|
return []FactEntry{markerFact}
|
|
}
|
|
|
|
// Count by guest
|
|
guestCounts := make(map[string]int)
|
|
for _, s := range resp.Snapshots {
|
|
name := s.VMName
|
|
if name == "" {
|
|
name = fmt.Sprintf("%d", s.VMID)
|
|
}
|
|
guestCounts[name]++
|
|
}
|
|
|
|
var summaryParts []string
|
|
count := 0
|
|
for guest, cnt := range guestCounts {
|
|
if count >= 5 {
|
|
break
|
|
}
|
|
summaryParts = append(summaryParts, fmt.Sprintf("%s: %d", guest, cnt))
|
|
count++
|
|
}
|
|
|
|
summaryValue := fmt.Sprintf("%d total; %s", total, strings.Join(summaryParts, ", "))
|
|
|
|
return []FactEntry{markerFact, {
|
|
Category: FactCategoryStorage,
|
|
Key: "snapshots:summary",
|
|
Value: truncateValue(summaryValue),
|
|
}}
|
|
}
|
|
|
|
// --- pulse_storage: replication ---
|
|
|
|
func extractReplicationFacts(resultText string) []FactEntry {
|
|
// Response is TEXT-wrapped JSON array
|
|
var jobs []struct {
|
|
ID string `json:"id"`
|
|
GuestID int `json:"guest_id"`
|
|
GuestName string `json:"guest_name"`
|
|
SourceNode string `json:"source_node"`
|
|
TargetNode string `json:"target_node"`
|
|
Status string `json:"status"`
|
|
Error string `json:"error"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &jobs); err != nil {
|
|
return nil
|
|
}
|
|
|
|
markerFact := FactEntry{
|
|
Category: FactCategoryStorage,
|
|
Key: "replication:queried",
|
|
Value: fmt.Sprintf("%d jobs", len(jobs)),
|
|
}
|
|
|
|
if len(jobs) == 0 {
|
|
return []FactEntry{markerFact}
|
|
}
|
|
|
|
errorCount := 0
|
|
var errorNames []string
|
|
for _, j := range jobs {
|
|
if j.Error != "" {
|
|
errorCount++
|
|
name := j.GuestName
|
|
if name == "" {
|
|
name = fmt.Sprintf("%d", j.GuestID)
|
|
}
|
|
errorNames = append(errorNames, name)
|
|
}
|
|
}
|
|
|
|
var summaryValue string
|
|
if errorCount == 0 {
|
|
summaryValue = fmt.Sprintf("%d jobs, all ok", len(jobs))
|
|
} else {
|
|
summaryValue = fmt.Sprintf("%d jobs, %d with errors: %s", len(jobs), errorCount, strings.Join(errorNames, ", "))
|
|
}
|
|
|
|
return []FactEntry{markerFact, {
|
|
Category: FactCategoryStorage,
|
|
Key: "replication:summary",
|
|
Value: truncateValue(summaryValue),
|
|
}}
|
|
}
|
|
|
|
// --- pulse_storage: pbs_jobs ---
|
|
|
|
func extractPBSJobsFacts(resultText string) []FactEntry {
|
|
var resp struct {
|
|
Instance string `json:"instance"`
|
|
Jobs []struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Store string `json:"store"`
|
|
Status string `json:"status"`
|
|
Error string `json:"error"`
|
|
} `json:"jobs"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
total := resp.Total
|
|
if total == 0 {
|
|
total = len(resp.Jobs)
|
|
}
|
|
|
|
markerFact := FactEntry{
|
|
Category: FactCategoryStorage,
|
|
Key: "pbs_jobs:queried",
|
|
Value: fmt.Sprintf("%d jobs", total),
|
|
}
|
|
|
|
if total == 0 {
|
|
return []FactEntry{markerFact}
|
|
}
|
|
|
|
// Count by type and errors
|
|
typeCounts := make(map[string]int)
|
|
errorCount := 0
|
|
for _, j := range resp.Jobs {
|
|
jType := j.Type
|
|
if jType == "" {
|
|
jType = "unknown"
|
|
}
|
|
typeCounts[jType]++
|
|
if j.Error != "" {
|
|
errorCount++
|
|
}
|
|
}
|
|
|
|
var typeParts []string
|
|
for jType, cnt := range typeCounts {
|
|
typeParts = append(typeParts, fmt.Sprintf("%d %s", cnt, jType))
|
|
}
|
|
|
|
summaryValue := strings.Join(typeParts, ", ")
|
|
if errorCount > 0 {
|
|
summaryValue += fmt.Sprintf("; %d with errors", errorCount)
|
|
}
|
|
|
|
return []FactEntry{markerFact, {
|
|
Category: FactCategoryStorage,
|
|
Key: "pbs_jobs:summary",
|
|
Value: truncateValue(summaryValue),
|
|
}}
|
|
}
|
|
|
|
// --- pulse_storage: resource_disks ---
|
|
|
|
func extractResourceDisksFacts(resultText string) []FactEntry {
|
|
var resp struct {
|
|
Resources []struct {
|
|
VMID int `json:"vmid"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Node string `json:"node"`
|
|
Disks []struct {
|
|
Mountpoint string `json:"mountpoint"`
|
|
Usage float64 `json:"usage_percent"`
|
|
} `json:"disks"`
|
|
} `json:"resources"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultText), &resp); err != nil {
|
|
return nil
|
|
}
|
|
|
|
total := resp.Total
|
|
if total == 0 {
|
|
total = len(resp.Resources)
|
|
}
|
|
|
|
markerFact := FactEntry{
|
|
Category: FactCategoryStorage,
|
|
Key: "resource_disks:queried",
|
|
Value: fmt.Sprintf("%d resources", total),
|
|
}
|
|
|
|
if total == 0 {
|
|
return []FactEntry{markerFact}
|
|
}
|
|
|
|
totalDisks := 0
|
|
highUsage := 0
|
|
for _, r := range resp.Resources {
|
|
totalDisks += len(r.Disks)
|
|
for _, d := range r.Disks {
|
|
if d.Usage > 80 {
|
|
highUsage++
|
|
}
|
|
}
|
|
}
|
|
|
|
summaryValue := fmt.Sprintf("%d resources, %d disks total, %d disks over 80%% usage", total, totalDisks, highUsage)
|
|
|
|
return []FactEntry{markerFact, {
|
|
Category: FactCategoryStorage,
|
|
Key: "resource_disks:summary",
|
|
Value: truncateValue(summaryValue),
|
|
}}
|
|
}
|
|
|
|
// MarkerExpansions maps marker fact keys to the prefix used to find related per-resource facts.
|
|
// Used by the gate to enrich marker-based cache hits with actual data.
|
|
var MarkerExpansions = map[string]string{
|
|
// Storage markers
|
|
"storage:pools:queried": "storage:",
|
|
"disk_health:queried": "disk_health:",
|
|
"raid:queried": "raid:",
|
|
"backups:queried": "backups:",
|
|
"ceph:queried": "ceph:",
|
|
"ceph_details:queried": "ceph_details:",
|
|
"snapshots:queried": "snapshots:",
|
|
"replication:queried": "replication:",
|
|
"pbs_jobs:queried": "pbs_jobs:",
|
|
"resource_disks:queried": "resource_disks:",
|
|
"backup_tasks:queried": "backup:",
|
|
// Metrics markers
|
|
"baselines:queried": "baseline:",
|
|
"physical_disks:queried": "physical_disks:",
|
|
"temperatures:queried": "temperatures:",
|
|
// Docker markers
|
|
"docker_services:queried": "docker_services:",
|
|
// Kubernetes markers
|
|
"k8s_clusters:queried": "k8s_cluster:",
|
|
// PMG markers
|
|
"pmg:queried": "pmg:",
|
|
// Alert & finding markers → expand to per-item facts
|
|
"alerts:overview": "alert:",
|
|
"findings:overview": "finding:",
|
|
"patrol_findings:queried": "patrol_findings:",
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func strFromMap(m map[string]interface{}, key string) string {
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
v, ok := m[key]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return s
|
|
}
|
|
|
|
func truncateValue(s string) string {
|
|
if len(s) > maxValueLen {
|
|
return s[:maxValueLen]
|
|
}
|
|
return s
|
|
}
|