mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
3560 lines
109 KiB
Go
3560 lines
109 KiB
Go
// Package servicediscovery provides infrastructure discovery capabilities.
|
|
// It discovers services, versions, configurations, and CLI access methods
|
|
// for VMs, LXCs, Docker containers, Kubernetes pods, and hosts.
|
|
package servicediscovery
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/jsonresponse"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// sensitiveKeyPatterns defines patterns that indicate a label/env key might contain secrets.
|
|
// These patterns are case-insensitive and match if any part of the key contains them.
|
|
var sensitiveKeyPatterns = []string{
|
|
"password", "passwd", "pwd",
|
|
"secret",
|
|
"key", "apikey", "api_key",
|
|
"token",
|
|
"credential", "cred",
|
|
"auth",
|
|
"private",
|
|
"cert",
|
|
}
|
|
|
|
// filterSensitiveLabels removes or redacts labels that may contain sensitive values.
|
|
// It returns a new map with sensitive values replaced with "[REDACTED]".
|
|
// Keys are checked case-insensitively for sensitive patterns.
|
|
func filterSensitiveLabels(labels map[string]string) map[string]string {
|
|
if labels == nil {
|
|
return nil
|
|
}
|
|
|
|
filtered := make(map[string]string, len(labels))
|
|
redactedCount := 0
|
|
|
|
for key, value := range labels {
|
|
keyLower := strings.ToLower(key)
|
|
isSensitive := false
|
|
|
|
for _, pattern := range sensitiveKeyPatterns {
|
|
if strings.Contains(keyLower, pattern) {
|
|
isSensitive = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if isSensitive {
|
|
filtered[key] = "[REDACTED]"
|
|
redactedCount++
|
|
} else {
|
|
filtered[key] = value
|
|
}
|
|
}
|
|
|
|
if redactedCount > 0 {
|
|
log.Debug().
|
|
Int("redacted_count", redactedCount).
|
|
Int("total_labels", len(labels)).
|
|
Msg("Redacted sensitive labels before AI analysis")
|
|
}
|
|
|
|
return filtered
|
|
}
|
|
|
|
// StateSnapshot holds an infrastructure snapshot used internally by the
|
|
// discovery service. Previously a shadow StateProvider interface provided
|
|
// this; now it is built exclusively from ReadState typed views.
|
|
type StateSnapshot struct {
|
|
VMs []VM
|
|
Containers []Container
|
|
DockerHosts []DockerHost
|
|
KubernetesClusters []KubernetesCluster
|
|
Hosts []Host
|
|
Nodes []Node
|
|
}
|
|
|
|
// EmptyStateSnapshot returns a canonical empty servicediscovery snapshot with
|
|
// stable collection semantics across miss/fallback paths.
|
|
func EmptyStateSnapshot() StateSnapshot {
|
|
return StateSnapshot{}.NormalizeCollections()
|
|
}
|
|
|
|
// NormalizeCollections ensures StateSnapshot never leaks nil slices through
|
|
// service-discovery owner boundaries.
|
|
func (s StateSnapshot) NormalizeCollections() StateSnapshot {
|
|
if s.VMs == nil {
|
|
s.VMs = []VM{}
|
|
}
|
|
if s.Containers == nil {
|
|
s.Containers = []Container{}
|
|
}
|
|
if s.DockerHosts == nil {
|
|
s.DockerHosts = []DockerHost{}
|
|
}
|
|
if s.KubernetesClusters == nil {
|
|
s.KubernetesClusters = []KubernetesCluster{}
|
|
}
|
|
if s.Hosts == nil {
|
|
s.Hosts = []Host{}
|
|
}
|
|
if s.Nodes == nil {
|
|
s.Nodes = []Node{}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Node represents a Proxmox VE node.
|
|
type Node struct {
|
|
ID string
|
|
Name string
|
|
LinkedAgentID string
|
|
}
|
|
|
|
// Host represents a host system (via pulse-agent host telemetry).
|
|
type Host struct {
|
|
ID string
|
|
Hostname string
|
|
DisplayName string
|
|
Platform string // e.g., "linux", "darwin", "windows"
|
|
OSName string // e.g., "Unraid", "Ubuntu", "Debian"
|
|
OSVersion string
|
|
KernelVersion string
|
|
Architecture string // e.g., "amd64", "arm64"
|
|
CPUCount int
|
|
Status string
|
|
Tags []string
|
|
}
|
|
|
|
// VM represents a virtual machine.
|
|
type VM struct {
|
|
VMID int
|
|
Name string
|
|
Node string
|
|
Status string
|
|
Instance string
|
|
// Additional metadata for fingerprinting
|
|
CPUs int // Number of CPU cores
|
|
MaxMemory uint64 // Max memory in bytes
|
|
MaxDisk uint64 // Max disk in bytes
|
|
Tags []string // User-defined tags
|
|
OSName string // Detected OS name
|
|
OSVersion string // OS version string
|
|
IPAddresses []string // IP addresses assigned to the VM
|
|
Template bool // True if this is a template
|
|
}
|
|
|
|
// Container represents an LXC container.
|
|
type Container struct {
|
|
VMID int
|
|
Name string
|
|
Node string
|
|
Status string
|
|
Instance string
|
|
// Additional metadata for fingerprinting
|
|
CPUs int // Number of CPU cores
|
|
MaxMemory uint64 // Max memory in bytes
|
|
MaxDisk uint64 // Max disk in bytes
|
|
Tags []string // User-defined tags
|
|
OSTemplate string // Template or OCI image used
|
|
OSName string // Detected OS name
|
|
IsOCI bool // True if OCI container (Proxmox 9.1+)
|
|
IPAddresses []string // IP addresses assigned to the container
|
|
Template bool // True if this is a template
|
|
}
|
|
|
|
// DockerHost represents a Docker host.
|
|
type DockerHost struct {
|
|
AgentID string
|
|
Hostname string
|
|
Containers []DockerContainer
|
|
}
|
|
|
|
// DockerContainer represents a Docker container.
|
|
type DockerContainer struct {
|
|
ID string
|
|
Name string
|
|
Image string
|
|
Status string
|
|
Ports []DockerPort
|
|
Labels map[string]string
|
|
Mounts []DockerMount
|
|
}
|
|
|
|
// DockerPort represents a port mapping.
|
|
type DockerPort struct {
|
|
PublicPort int
|
|
PrivatePort int
|
|
Protocol string
|
|
}
|
|
|
|
// DockerMount represents a mount point.
|
|
type DockerMount struct {
|
|
Source string
|
|
Destination string
|
|
}
|
|
|
|
// KubernetesCluster represents a Kubernetes cluster.
|
|
type KubernetesCluster struct {
|
|
ID string
|
|
Name string
|
|
AgentID string
|
|
Status string
|
|
Pods []KubernetesPod
|
|
}
|
|
|
|
// KubernetesPod represents a Kubernetes pod.
|
|
type KubernetesPod struct {
|
|
UID string
|
|
Name string
|
|
Namespace string
|
|
NodeName string
|
|
Phase string
|
|
Labels map[string]string
|
|
OwnerKind string // e.g., "Deployment", "StatefulSet", "DaemonSet"
|
|
OwnerName string
|
|
Containers []KubernetesPodContainer
|
|
}
|
|
|
|
// KubernetesPodContainer represents a container within a Kubernetes pod.
|
|
type KubernetesPodContainer struct {
|
|
Name string
|
|
Image string
|
|
Ready bool
|
|
RestartCount int32
|
|
State string // e.g., "running", "waiting", "terminated"
|
|
}
|
|
|
|
// AIAnalyzer provides AI analysis capabilities for discovery.
|
|
type AIAnalyzer interface {
|
|
AnalyzeForDiscovery(ctx context.Context, prompt string) (string, error)
|
|
}
|
|
|
|
// WSBroadcaster provides WebSocket broadcasting capabilities.
|
|
type WSBroadcaster interface {
|
|
BroadcastDiscoveryProgress(progress *DiscoveryProgress)
|
|
}
|
|
|
|
// Service manages infrastructure discovery.
|
|
type Service struct {
|
|
store *Store
|
|
scanner *DeepScanner
|
|
readState unifiedresources.ReadState // Typed state access (sole source since SRC-03l)
|
|
aiAnalyzer AIAnalyzer
|
|
wsHub WSBroadcaster // WebSocket hub for broadcasting progress
|
|
|
|
mu sync.RWMutex
|
|
running bool
|
|
stopping bool
|
|
commandScanning bool
|
|
stopCh chan struct{}
|
|
loopDone chan struct{}
|
|
runCancel context.CancelFunc
|
|
backfillCancel context.CancelFunc
|
|
backfillDone chan struct{}
|
|
intervalCh chan time.Duration // Channel for live interval updates
|
|
interval time.Duration
|
|
initialDelay time.Duration
|
|
lastRun time.Time
|
|
deepScanTimeout time.Duration // Timeout for individual deep scans
|
|
aiAnalysisTimeout time.Duration // Timeout for individual AI analysis calls
|
|
maxDiscoveryAge time.Duration // Max age before rediscovery (default 30 days)
|
|
|
|
// Cache for AI analysis results (by image name)
|
|
analysisCache map[string]*analysisCacheEntry
|
|
cacheMu sync.RWMutex
|
|
cacheExpiry time.Duration
|
|
|
|
// In-progress discovery tracking (prevents duplicate concurrent discoveries)
|
|
inProgressMu sync.Mutex
|
|
inProgress map[string]*discoveryInProgress
|
|
}
|
|
|
|
// discoveryInProgress tracks an ongoing discovery operation.
|
|
// Multiple callers can wait on the done channel for completion.
|
|
type discoveryInProgress struct {
|
|
done chan struct{} // Closed when discovery completes
|
|
result *ResourceDiscovery // Result after completion
|
|
err error // Error after completion
|
|
}
|
|
|
|
// analysisCacheEntry holds a cached AI analysis result with its timestamp.
|
|
type analysisCacheEntry struct {
|
|
result *AIAnalysisResponse
|
|
cachedAt time.Time
|
|
}
|
|
|
|
// Config holds discovery service configuration.
|
|
type Config struct {
|
|
DataDir string
|
|
Interval time.Duration // How often to run fingerprint collection (default 5 min)
|
|
CacheExpiry time.Duration // How long to cache AI analysis results
|
|
DeepScanTimeout time.Duration // Timeout for individual deep scans (default 60s)
|
|
AIAnalysisTimeout time.Duration // Timeout for individual AI analysis calls (default 45s)
|
|
|
|
// Fingerprint-based discovery settings
|
|
MaxDiscoveryAge time.Duration // Rediscover after this duration (default 30 days)
|
|
FingerprintInterval time.Duration // How often to collect fingerprints (default 5 min)
|
|
CommandScanning bool // Enables agent command-backed deep scans
|
|
}
|
|
|
|
const (
|
|
defaultDiscoveryInterval = 5 * time.Minute
|
|
defaultDiscoveryCacheExpiry = 1 * time.Hour
|
|
defaultDiscoveryScanTimeout = 60 * time.Second
|
|
defaultDiscoveryMaxAge = 30 * 24 * time.Hour
|
|
minDiscoveryMaxAge = 24 * time.Hour
|
|
defaultDiscoveryInitialDelay = 30 * time.Second
|
|
)
|
|
|
|
// DefaultConfig returns the default discovery configuration.
|
|
func DefaultConfig() Config {
|
|
return Config{
|
|
Interval: defaultDiscoveryInterval, // Fingerprint collection interval
|
|
CacheExpiry: defaultDiscoveryCacheExpiry,
|
|
DeepScanTimeout: defaultDiscoveryScanTimeout,
|
|
MaxDiscoveryAge: defaultDiscoveryMaxAge,
|
|
FingerprintInterval: defaultDiscoveryInterval,
|
|
CommandScanning: false,
|
|
}
|
|
}
|
|
|
|
func normalizeDiscoveryInterval(interval time.Duration) time.Duration {
|
|
if interval > 0 {
|
|
return interval
|
|
}
|
|
log.Warn().Dur("interval", interval).Dur("default", defaultDiscoveryInterval).Msg("Invalid discovery interval; using default")
|
|
return defaultDiscoveryInterval
|
|
}
|
|
|
|
func normalizeDeepScanTimeout(timeout time.Duration) time.Duration {
|
|
if timeout > 0 {
|
|
return timeout
|
|
}
|
|
log.Warn().Dur("deep_scan_timeout", timeout).Dur("default", defaultDiscoveryScanTimeout).Msg("Invalid deep scan timeout; using default")
|
|
return defaultDiscoveryScanTimeout
|
|
}
|
|
|
|
// NewService creates a new discovery service.
|
|
func NewService(store *Store, scanner *DeepScanner, cfg Config) *Service {
|
|
if cfg.Interval == 0 {
|
|
cfg.Interval = 5 * time.Minute
|
|
}
|
|
if cfg.CacheExpiry == 0 {
|
|
cfg.CacheExpiry = 1 * time.Hour
|
|
}
|
|
if cfg.DeepScanTimeout == 0 {
|
|
cfg.DeepScanTimeout = 60 * time.Second
|
|
}
|
|
if cfg.AIAnalysisTimeout <= 0 {
|
|
cfg.AIAnalysisTimeout = 45 * time.Second
|
|
}
|
|
if cfg.MaxDiscoveryAge == 0 {
|
|
cfg.MaxDiscoveryAge = 30 * 24 * time.Hour // 30 days
|
|
}
|
|
|
|
return &Service{
|
|
store: store,
|
|
scanner: scanner,
|
|
interval: cfg.Interval,
|
|
initialDelay: 30 * time.Second,
|
|
cacheExpiry: cfg.CacheExpiry,
|
|
deepScanTimeout: cfg.DeepScanTimeout,
|
|
aiAnalysisTimeout: cfg.AIAnalysisTimeout,
|
|
maxDiscoveryAge: cfg.MaxDiscoveryAge,
|
|
stopCh: make(chan struct{}),
|
|
intervalCh: make(chan time.Duration, 1), // Buffered to prevent blocking
|
|
analysisCache: make(map[string]*analysisCacheEntry),
|
|
inProgress: make(map[string]*discoveryInProgress),
|
|
commandScanning: cfg.CommandScanning,
|
|
}
|
|
}
|
|
|
|
// SetCommandScanningEnabled controls whether this service may dispatch
|
|
// command-backed deep scans through connected Pulse agents.
|
|
func (s *Service) SetCommandScanningEnabled(enabled bool) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
s.commandScanning = enabled
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// IsCommandScanningEnabled reports whether command-backed deep scans may run.
|
|
func (s *Service) IsCommandScanningEnabled() bool {
|
|
if s == nil {
|
|
return false
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.commandScanning
|
|
}
|
|
|
|
// SetAIAnalyzer sets the AI analyzer for discovery.
|
|
func (s *Service) SetAIAnalyzer(analyzer AIAnalyzer) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.aiAnalyzer = analyzer
|
|
}
|
|
|
|
// SetReadState sets the typed ReadState provider for the discovery service.
|
|
// When set, getSnapshot() uses ReadState to build infrastructure snapshots.
|
|
func (s *Service) SetReadState(rs unifiedresources.ReadState) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
|
|
s.mu.Lock()
|
|
previousCancel := s.backfillCancel
|
|
previousDone := s.backfillDone
|
|
s.readState = rs
|
|
s.backfillCancel = cancel
|
|
s.backfillDone = done
|
|
s.mu.Unlock()
|
|
|
|
if previousCancel != nil {
|
|
previousCancel()
|
|
}
|
|
go func() {
|
|
defer close(done)
|
|
if previousDone != nil {
|
|
<-previousDone
|
|
}
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
s.backfillAvailabilitySuggestions(ctx)
|
|
}()
|
|
}
|
|
|
|
// Start begins the background discovery service.
|
|
func (s *Service) Start(ctx context.Context) {
|
|
s.mu.Lock()
|
|
if s.running || s.stopping {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
if !s.commandScanning {
|
|
s.mu.Unlock()
|
|
log.Info().Msg("Discovery command scanning disabled; background discovery service not started")
|
|
return
|
|
}
|
|
runCtx, cancel := context.WithCancel(ctx)
|
|
s.running = true
|
|
stopCh := make(chan struct{})
|
|
loopDone := make(chan struct{})
|
|
s.stopCh = stopCh
|
|
s.loopDone = loopDone
|
|
s.runCancel = cancel
|
|
s.mu.Unlock()
|
|
|
|
log.Info().
|
|
Dur("interval", s.interval).
|
|
Msg("Starting infrastructure discovery service")
|
|
|
|
go s.runDiscoveryLoop(runCtx, stopCh, loopDone)
|
|
}
|
|
|
|
// Stop stops the background discovery service.
|
|
func (s *Service) Stop() {
|
|
s.mu.Lock()
|
|
s.running = false
|
|
stopCh := s.stopCh
|
|
loopDone := s.loopDone
|
|
runCancel := s.runCancel
|
|
backfillCancel := s.backfillCancel
|
|
backfillDone := s.backfillDone
|
|
s.stopCh = nil
|
|
s.loopDone = nil
|
|
s.runCancel = nil
|
|
s.backfillCancel = nil
|
|
s.backfillDone = nil
|
|
s.mu.Unlock()
|
|
|
|
if backfillCancel != nil {
|
|
backfillCancel()
|
|
}
|
|
if runCancel != nil {
|
|
runCancel()
|
|
}
|
|
if stopCh != nil {
|
|
close(stopCh)
|
|
}
|
|
if loopDone != nil {
|
|
<-loopDone
|
|
}
|
|
if backfillDone != nil {
|
|
<-backfillDone
|
|
}
|
|
}
|
|
|
|
// SetInterval updates the scan interval. Takes effect immediately if running.
|
|
func (s *Service) SetInterval(interval time.Duration) {
|
|
normalizedInterval := normalizeDiscoveryInterval(interval)
|
|
|
|
s.mu.Lock()
|
|
s.interval = normalizedInterval
|
|
running := s.running
|
|
s.mu.Unlock()
|
|
|
|
// If running, send the new interval to the loop (non-blocking)
|
|
if running {
|
|
select {
|
|
case s.intervalCh <- normalizedInterval:
|
|
log.Info().Dur("interval", normalizedInterval).Msg("Discovery interval updated (live)")
|
|
default:
|
|
// Channel full, interval will be picked up eventually
|
|
log.Debug().Dur("interval", normalizedInterval).Msg("Discovery interval updated (pending)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// needsDeepScan determines if a discovery result needs a deep scan based on quality.
|
|
// Returns true if the discovery is incomplete or low-confidence.
|
|
func (s *Service) needsDeepScan(discovery *ResourceDiscovery) bool {
|
|
if discovery == nil {
|
|
return true // No discovery at all
|
|
}
|
|
|
|
// Already has deep scan data (raw command outputs)
|
|
if len(discovery.RawCommandOutput) > 0 {
|
|
return false
|
|
}
|
|
|
|
// Low confidence - needs more investigation
|
|
if discovery.Confidence < 0.7 {
|
|
return true
|
|
}
|
|
|
|
// Unknown service type
|
|
if discovery.ServiceType == "" || discovery.ServiceType == "unknown" {
|
|
return true
|
|
}
|
|
|
|
// Missing key paths that deep scan could discover
|
|
if len(discovery.Facts) == 0 && len(discovery.ConfigPaths) == 0 && len(discovery.LogPaths) == 0 {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// SetWSHub sets the WebSocket hub for broadcasting progress updates.
|
|
func (s *Service) SetWSHub(hub WSBroadcaster) {
|
|
s.mu.Lock()
|
|
s.wsHub = hub
|
|
s.mu.Unlock()
|
|
|
|
// Wire up the scanner's progress callback to broadcast via WebSocket
|
|
if s.scanner != nil {
|
|
s.scanner.SetProgressCallback(s.broadcastProgress)
|
|
}
|
|
|
|
log.Info().Msg("webSocket hub connected to discovery service")
|
|
}
|
|
|
|
// broadcastProgress broadcasts discovery progress to all WebSocket clients.
|
|
func (s *Service) broadcastProgress(progress *DiscoveryProgress) {
|
|
s.mu.RLock()
|
|
hub := s.wsHub
|
|
s.mu.RUnlock()
|
|
|
|
if hub == nil || progress == nil {
|
|
return
|
|
}
|
|
|
|
hub.BroadcastDiscoveryProgress(progress)
|
|
}
|
|
|
|
// IsRunning returns whether the background discovery loop is active.
|
|
func (s *Service) IsRunning() bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.running
|
|
}
|
|
|
|
// discoveryLoop runs periodic fingerprint collection and automatic refreshes.
|
|
// Fingerprints detect changes cheaply; changed/stale/new resources are then refreshed.
|
|
func (s *Service) discoveryLoop(ctx context.Context) {
|
|
s.mu.RLock()
|
|
stopCh := s.stopCh
|
|
s.mu.RUnlock()
|
|
s.runDiscoveryLoop(ctx, stopCh, nil)
|
|
}
|
|
|
|
func (s *Service) runDiscoveryLoop(ctx context.Context, stopCh <-chan struct{}, done chan<- struct{}) {
|
|
if done != nil {
|
|
defer close(done)
|
|
}
|
|
|
|
delay := s.initialDelay
|
|
if delay <= 0 {
|
|
delay = defaultDiscoveryInitialDelay
|
|
}
|
|
|
|
startupTimer := time.NewTimer(delay)
|
|
defer startupTimer.Stop()
|
|
|
|
// Run initial fingerprint collection after a short delay
|
|
select {
|
|
case <-startupTimer.C:
|
|
case <-stopCh:
|
|
return
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
|
|
s.collectFingerprints(ctx)
|
|
s.runAutomaticDiscoveryRefresh(ctx)
|
|
s.backfillAvailabilitySuggestions(ctx)
|
|
|
|
s.mu.RLock()
|
|
currentInterval := s.interval
|
|
s.mu.RUnlock()
|
|
currentInterval = normalizeDiscoveryInterval(currentInterval)
|
|
|
|
ticker := time.NewTicker(currentInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
s.collectFingerprints(ctx)
|
|
s.runAutomaticDiscoveryRefresh(ctx)
|
|
s.backfillAvailabilitySuggestions(ctx)
|
|
case newInterval := <-s.intervalCh:
|
|
// Interval changed - reset the ticker
|
|
newInterval = normalizeDiscoveryInterval(newInterval)
|
|
ticker.Stop()
|
|
ticker = time.NewTicker(newInterval)
|
|
log.Info().Dur("interval", newInterval).Msg("Fingerprint collection interval reset")
|
|
case <-stopCh:
|
|
log.Info().Msg("Stopping discovery service")
|
|
return
|
|
case <-ctx.Done():
|
|
log.Info().Msg("discovery context cancelled")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) runAutomaticDiscoveryRefresh(ctx context.Context) {
|
|
if _, err := s.runDiscoveryRefresh(ctx, "automatic"); err != nil {
|
|
log.Debug().Err(err).Msg("skipping automatic discovery refresh")
|
|
}
|
|
}
|
|
|
|
// RunManualDiscoveryRefresh runs the scheduler-equivalent discovery refresh on demand.
|
|
func (s *Service) RunManualDiscoveryRefresh(ctx context.Context) (DiscoveryRefreshSummary, error) {
|
|
if ctx == nil || ctx.Err() != nil || s.store == nil {
|
|
return DiscoveryRefreshSummary{Mode: "manual"}, fmt.Errorf("discovery service is not ready")
|
|
}
|
|
if !s.IsCommandScanningEnabled() {
|
|
return DiscoveryRefreshSummary{Mode: "manual"}, fmt.Errorf("discovery command scanning is disabled")
|
|
}
|
|
|
|
s.collectFingerprints(ctx)
|
|
return s.runDiscoveryRefresh(ctx, "manual")
|
|
}
|
|
|
|
func (s *Service) runDiscoveryRefresh(ctx context.Context, mode string) (DiscoveryRefreshSummary, error) {
|
|
summary := DiscoveryRefreshSummary{Mode: mode}
|
|
if ctx == nil || ctx.Err() != nil || s.store == nil {
|
|
return summary, fmt.Errorf("discovery service is not ready")
|
|
}
|
|
if !s.IsCommandScanningEnabled() {
|
|
return summary, fmt.Errorf("discovery command scanning is disabled")
|
|
}
|
|
|
|
s.mu.RLock()
|
|
analyzerConfigured := s.aiAnalyzer != nil
|
|
maxDiscoveryAge := s.maxDiscoveryAge
|
|
s.mu.RUnlock()
|
|
if !analyzerConfigured {
|
|
return summary, fmt.Errorf("AI analyzer not configured")
|
|
}
|
|
|
|
changedResources, err := s.store.GetChangedResources()
|
|
if err != nil {
|
|
return summary, fmt.Errorf("fetch changed resources: %w", err)
|
|
}
|
|
staleResources, err := s.store.GetStaleResources(maxDiscoveryAge)
|
|
if err != nil {
|
|
return summary, fmt.Errorf("fetch stale resources: %w", err)
|
|
}
|
|
repairableResources, err := s.getKnownServiceRepairCandidates()
|
|
if err != nil {
|
|
return summary, fmt.Errorf("fetch repairable discoveries: %w", err)
|
|
}
|
|
|
|
candidates := make(map[string]struct{}, len(changedResources)+len(staleResources)+len(repairableResources))
|
|
for _, id := range changedResources {
|
|
if strings.TrimSpace(id) != "" {
|
|
candidates[id] = struct{}{}
|
|
}
|
|
}
|
|
for _, id := range staleResources {
|
|
if strings.TrimSpace(id) != "" {
|
|
candidates[id] = struct{}{}
|
|
}
|
|
}
|
|
for _, id := range repairableResources {
|
|
if strings.TrimSpace(id) != "" {
|
|
candidates[id] = struct{}{}
|
|
}
|
|
}
|
|
|
|
summary.ChangedCount = len(changedResources)
|
|
summary.StaleCount = len(staleResources)
|
|
summary.CandidateCount = len(candidates)
|
|
summary.FingerprintCount = s.store.GetFingerprintCount()
|
|
summary.LastRun = time.Now()
|
|
|
|
if len(candidates) == 0 {
|
|
return summary, nil
|
|
}
|
|
|
|
resourceIDs := make([]string, 0, len(candidates))
|
|
for id := range candidates {
|
|
resourceIDs = append(resourceIDs, id)
|
|
}
|
|
sort.Strings(resourceIDs)
|
|
|
|
log.Info().
|
|
Str("mode", mode).
|
|
Int("changed", len(changedResources)).
|
|
Int("stale", len(staleResources)).
|
|
Int("repairable", len(repairableResources)).
|
|
Int("total", len(resourceIDs)).
|
|
Msg("Running discovery refresh for changed, stale, or repairable resources")
|
|
|
|
for _, id := range resourceIDs {
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
|
|
resourceType, targetID, resourceID, err := ParseResourceID(id)
|
|
if err != nil {
|
|
summary.FailedCount++
|
|
log.Warn().
|
|
Err(err).
|
|
Str("resource_id", id).
|
|
Msg("Skipping invalid resource ID during automatic discovery refresh")
|
|
continue
|
|
}
|
|
|
|
_, err = s.DiscoverResource(ctx, DiscoveryRequest{
|
|
ResourceType: resourceType,
|
|
ResourceID: resourceID,
|
|
TargetID: targetID,
|
|
})
|
|
if err != nil {
|
|
summary.FailedCount++
|
|
log.Warn().
|
|
Err(err).
|
|
Str("resource_id", id).
|
|
Str("resource_type", string(resourceType)).
|
|
Msg("Discovery refresh failed for resource")
|
|
continue
|
|
}
|
|
summary.DiscoveredCount++
|
|
}
|
|
|
|
log.Info().
|
|
Str("mode", mode).
|
|
Int("discovered", summary.DiscoveredCount).
|
|
Int("failed", summary.FailedCount).
|
|
Msg("Discovery refresh completed")
|
|
|
|
return summary, nil
|
|
}
|
|
|
|
func (s *Service) getKnownServiceRepairCandidates() ([]string, error) {
|
|
discoveries, err := s.store.List()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list discoveries for repair scan: %w", err)
|
|
}
|
|
|
|
var candidates []string
|
|
for _, discovery := range discoveries {
|
|
if discovery == nil {
|
|
continue
|
|
}
|
|
req := discoveryRequestForStoredDiscovery(discovery)
|
|
if req.ResourceType == "" || req.TargetID == "" || req.ResourceID == "" {
|
|
continue
|
|
}
|
|
if s.discoveryNeedsKnownServiceRepair(discovery, req) {
|
|
candidates = append(candidates, discovery.ID)
|
|
}
|
|
}
|
|
return candidates, nil
|
|
}
|
|
|
|
func discoveryRequestForStoredDiscovery(discovery *ResourceDiscovery) DiscoveryRequest {
|
|
if discovery == nil {
|
|
return DiscoveryRequest{}
|
|
}
|
|
|
|
req := DiscoveryRequest{
|
|
ResourceType: discovery.ResourceType,
|
|
TargetID: canonicalDiscoveryTargetID(discovery),
|
|
ResourceID: discovery.ResourceID,
|
|
Hostname: discovery.Hostname,
|
|
}
|
|
if req.ResourceType != "" && req.TargetID != "" && req.ResourceID != "" {
|
|
return req
|
|
}
|
|
|
|
resourceType, targetID, resourceID, err := ParseResourceID(discovery.ID)
|
|
if err != nil {
|
|
return req
|
|
}
|
|
if req.ResourceType == "" {
|
|
req.ResourceType = resourceType
|
|
}
|
|
if req.TargetID == "" {
|
|
req.TargetID = targetID
|
|
}
|
|
if req.ResourceID == "" {
|
|
req.ResourceID = resourceID
|
|
}
|
|
return req
|
|
}
|
|
|
|
func (s *Service) discoveryNeedsKnownServiceRepair(discovery *ResourceDiscovery, req DiscoveryRequest) bool {
|
|
if discovery == nil {
|
|
return false
|
|
}
|
|
|
|
var metadata map[string]any
|
|
if s.hasStateAccess() {
|
|
metadata = s.getResourceMetadata(req)
|
|
if req.Hostname == "" {
|
|
req.Hostname = stringMetadataValue(metadata, "name", "hostname", "display_name")
|
|
}
|
|
}
|
|
return knownServiceIdentityWouldImprove(discovery, req, metadata, discovery.RawCommandOutput)
|
|
}
|
|
|
|
// getSnapshot returns the current infrastructure state from ReadState.
|
|
// Returns an empty snapshot and false if ReadState is not yet configured.
|
|
func (s *Service) getSnapshot() (StateSnapshot, bool) {
|
|
rs := s.getReadState()
|
|
if rs != nil {
|
|
return s.snapshotFromReadState(rs), true
|
|
}
|
|
return EmptyStateSnapshot(), false
|
|
}
|
|
|
|
// getReadState returns the ReadState provider, safely reading it under the
|
|
// service lock to avoid races with concurrent SetReadState calls.
|
|
func (s *Service) getReadState() unifiedresources.ReadState {
|
|
s.mu.RLock()
|
|
rs := s.readState
|
|
s.mu.RUnlock()
|
|
return rs
|
|
}
|
|
|
|
// hasStateAccess returns true when ReadState is configured. Safe for
|
|
// concurrent use.
|
|
func (s *Service) hasStateAccess() bool {
|
|
return s.getReadState() != nil
|
|
}
|
|
|
|
// snapshotFromReadState converts ReadState typed views into the local
|
|
// StateSnapshot type used by servicediscovery functions.
|
|
func (s *Service) snapshotFromReadState(rs unifiedresources.ReadState) StateSnapshot {
|
|
// VMs
|
|
vmViews := rs.VMs()
|
|
vms := make([]VM, 0, len(vmViews))
|
|
for _, v := range vmViews {
|
|
vms = append(vms, VM{
|
|
VMID: v.VMID(),
|
|
Name: v.Name(),
|
|
Node: v.Node(),
|
|
Status: string(v.Status()),
|
|
Instance: v.Instance(),
|
|
OSName: v.OSName(),
|
|
OSVersion: v.OSVersion(),
|
|
IPAddresses: v.IPAddresses(),
|
|
Template: v.Template(),
|
|
})
|
|
}
|
|
|
|
// LXC containers
|
|
ctViews := rs.Containers()
|
|
containers := make([]Container, 0, len(ctViews))
|
|
for _, v := range ctViews {
|
|
containers = append(containers, Container{
|
|
VMID: v.VMID(),
|
|
Name: v.Name(),
|
|
Node: v.Node(),
|
|
Status: string(v.Status()),
|
|
Instance: v.Instance(),
|
|
OSTemplate: v.OSTemplate(),
|
|
OSName: v.OSName(),
|
|
IsOCI: v.IsOCI(),
|
|
IPAddresses: v.IPAddresses(),
|
|
Template: v.Template(),
|
|
})
|
|
}
|
|
|
|
// Docker hosts — build host → children map from flat DockerContainers list
|
|
dcViews := rs.DockerContainers()
|
|
childrenByParent := make(map[string][]DockerContainer, len(dcViews))
|
|
for _, dc := range dcViews {
|
|
parentID := dc.ParentID()
|
|
ports := dc.Ports()
|
|
sdPorts := make([]DockerPort, 0, len(ports))
|
|
for _, p := range ports {
|
|
sdPorts = append(sdPorts, DockerPort{
|
|
PublicPort: p.PublicPort,
|
|
PrivatePort: p.PrivatePort,
|
|
Protocol: p.Protocol,
|
|
})
|
|
}
|
|
mounts := dc.Mounts()
|
|
sdMounts := make([]DockerMount, 0, len(mounts))
|
|
for _, m := range mounts {
|
|
sdMounts = append(sdMounts, DockerMount{
|
|
Source: m.Source,
|
|
Destination: m.Destination,
|
|
})
|
|
}
|
|
childrenByParent[parentID] = append(childrenByParent[parentID], DockerContainer{
|
|
ID: dc.ContainerID(),
|
|
Name: dc.Name(),
|
|
Image: dc.Image(),
|
|
Status: string(dc.Status()),
|
|
Ports: sdPorts,
|
|
Labels: dc.Labels(),
|
|
Mounts: sdMounts,
|
|
})
|
|
}
|
|
|
|
dhViews := rs.DockerHosts()
|
|
dockerHosts := make([]DockerHost, 0, len(dhViews))
|
|
for _, dh := range dhViews {
|
|
dockerHosts = append(dockerHosts, DockerHost{
|
|
AgentID: dh.AgentID(),
|
|
Hostname: dh.Hostname(),
|
|
Containers: childrenByParent[dh.ID()],
|
|
})
|
|
}
|
|
|
|
// Hosts
|
|
// Note: CPUCount is not available via HostView (not mapped in unifiedresources
|
|
// AgentData). This is a known data gap tracked as SRC-01c; it only affects the
|
|
// "cpu_count" metadata field in AI analysis prompts.
|
|
hViews := rs.Hosts()
|
|
hosts := make([]Host, 0, len(hViews))
|
|
for _, h := range hViews {
|
|
// Use AgentID (original agent ID) rather than the registry hash
|
|
// ID, because discovery lookup code matches against request IDs which
|
|
// use the source-level host agent ID.
|
|
hostID := h.AgentID()
|
|
if hostID == "" {
|
|
hostID = h.ID()
|
|
}
|
|
hosts = append(hosts, Host{
|
|
ID: hostID,
|
|
Hostname: h.Hostname(),
|
|
DisplayName: h.Name(),
|
|
Platform: h.Platform(),
|
|
OSName: h.OSName(),
|
|
OSVersion: h.OSVersion(),
|
|
KernelVersion: h.KernelVersion(),
|
|
Architecture: h.Architecture(),
|
|
Status: string(h.Status()),
|
|
Tags: h.Tags(),
|
|
})
|
|
}
|
|
|
|
// Nodes
|
|
nViews := rs.Nodes()
|
|
nodes := make([]Node, 0, len(nViews))
|
|
for _, n := range nViews {
|
|
// Use SourceID (original Proxmox node ID) rather than the registry
|
|
// hash ID, because discovery lookup code matches against request IDs
|
|
// which use the source-level node ID.
|
|
nodeID := n.SourceID()
|
|
if nodeID == "" {
|
|
nodeID = n.ID()
|
|
}
|
|
nodes = append(nodes, Node{
|
|
ID: nodeID,
|
|
Name: n.Name(),
|
|
LinkedAgentID: n.LinkedAgentID(),
|
|
})
|
|
}
|
|
|
|
// Kubernetes clusters (ReadState provides K8sClusters + Pods separately).
|
|
// Note: PodView does not expose NodeName or sub-container details; these
|
|
// fields are zero-valued.
|
|
clusterViews := rs.K8sClusters()
|
|
podViews := rs.Pods()
|
|
podsByCluster := make(map[string][]KubernetesPod, len(podViews))
|
|
for _, pv := range podViews {
|
|
parentID := pv.ParentID()
|
|
podsByCluster[parentID] = append(podsByCluster[parentID], KubernetesPod{
|
|
UID: pv.PodUID(),
|
|
Name: pv.Name(),
|
|
Namespace: pv.Namespace(),
|
|
Phase: pv.PodPhase(),
|
|
Labels: pv.Labels(),
|
|
OwnerKind: pv.OwnerKind(),
|
|
OwnerName: pv.OwnerName(),
|
|
})
|
|
}
|
|
clusters := make([]KubernetesCluster, 0, len(clusterViews))
|
|
for _, cv := range clusterViews {
|
|
clusters = append(clusters, KubernetesCluster{
|
|
ID: cv.ID(),
|
|
Name: cv.Name(),
|
|
AgentID: cv.AgentID(),
|
|
Status: string(cv.Status()),
|
|
Pods: podsByCluster[cv.ID()],
|
|
})
|
|
}
|
|
|
|
return StateSnapshot{
|
|
VMs: vms,
|
|
Containers: containers,
|
|
DockerHosts: dockerHosts,
|
|
Hosts: hosts,
|
|
Nodes: nodes,
|
|
KubernetesClusters: clusters,
|
|
}.NormalizeCollections()
|
|
}
|
|
|
|
// collectFingerprints collects fingerprints from all resources (Docker, LXC, VM).
|
|
// This is metadata-only and does not invoke the AI analyzer.
|
|
func (s *Service) collectFingerprints(ctx context.Context) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Error().Interface("panic", r).Stack().Msg("recovered from panic in fingerprint collection")
|
|
}
|
|
}()
|
|
|
|
s.mu.Lock()
|
|
s.lastRun = time.Now()
|
|
s.mu.Unlock()
|
|
|
|
snap, ok := s.getSnapshot()
|
|
if !ok {
|
|
return
|
|
}
|
|
changedCount := 0
|
|
newCount := 0
|
|
|
|
// Process Docker containers
|
|
for _, host := range snap.DockerHosts {
|
|
for _, container := range host.Containers {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
// Generate new fingerprint (prefixed with docker: to avoid collisions)
|
|
newFP := GenerateDockerFingerprint(host.AgentID, &container)
|
|
fpKey := "docker:" + host.AgentID + ":" + newFP.ResourceID
|
|
|
|
// Get previous fingerprint
|
|
oldFP, err := s.store.GetFingerprint(fpKey)
|
|
if err != nil {
|
|
log.Warn().
|
|
Err(err).
|
|
Str("resource_id", fpKey).
|
|
Str("container", container.Name).
|
|
Msg("Failed to load previous Docker fingerprint")
|
|
}
|
|
|
|
// Update the fingerprint's ResourceID to include prefix for storage
|
|
newFP.ResourceID = fpKey
|
|
|
|
// Save new fingerprint
|
|
if err := s.store.SaveFingerprint(newFP); err != nil {
|
|
log.Warn().Err(err).Str("container", container.Name).Msg("failed to save Docker fingerprint")
|
|
continue
|
|
}
|
|
|
|
// Check if this is new or changed
|
|
if oldFP == nil {
|
|
newCount++
|
|
log.Debug().
|
|
Str("type", "docker").
|
|
Str("container", container.Name).
|
|
Str("hash", newFP.Hash).
|
|
Msg("New fingerprint captured")
|
|
} else if newFP.HasSchemaChanged(oldFP) {
|
|
// Schema changed - don't count as "changed" to avoid mass rediscovery
|
|
log.Debug().
|
|
Str("type", "docker").
|
|
Str("container", container.Name).
|
|
Int("old_schema", oldFP.SchemaVersion).
|
|
Int("new_schema", newFP.SchemaVersion).
|
|
Msg("Fingerprint schema updated")
|
|
} else if oldFP.Hash != newFP.Hash {
|
|
changedCount++
|
|
log.Info().
|
|
Str("type", "docker").
|
|
Str("container", container.Name).
|
|
Str("old_hash", oldFP.Hash).
|
|
Str("new_hash", newFP.Hash).
|
|
Msg("Fingerprint changed - discovery will run on next request")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process system containers (LXC)
|
|
lxcNew, lxcChanged := s.processFingerprint(ctx, GenerateLXCFingerprint, "system-container", "system-container:", snap.Containers)
|
|
newCount += lxcNew
|
|
changedCount += lxcChanged
|
|
|
|
// Process VMs
|
|
vmNew, vmChanged := s.processFingerprint(ctx, GenerateVMFingerprint, "vm", "vm:", snap.VMs)
|
|
newCount += vmNew
|
|
changedCount += vmChanged
|
|
|
|
// Process agent hosts (PVE nodes and standalone hosts with a Pulse agent).
|
|
// Without a fingerprint a host never enters GetChangedResources, so a new
|
|
// host would only ever be discovered by a manual per-resource trigger.
|
|
for i := range snap.Hosts {
|
|
host := &snap.Hosts[i]
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
if strings.TrimSpace(host.ID) == "" {
|
|
continue
|
|
}
|
|
|
|
newFP := GenerateHostFingerprint(host)
|
|
// Match the canonical host discovery key: agent:<agentID>:<agentID>.
|
|
fpKey := string(ResourceTypeAgent) + ":" + host.ID + ":" + host.ID
|
|
|
|
oldFP, err := s.store.GetFingerprint(fpKey)
|
|
if err != nil {
|
|
log.Warn().
|
|
Err(err).
|
|
Str("resource_id", fpKey).
|
|
Str("host", host.Hostname).
|
|
Msg("Failed to load previous host fingerprint")
|
|
}
|
|
|
|
newFP.ResourceID = fpKey
|
|
|
|
if err := s.store.SaveFingerprint(newFP); err != nil {
|
|
log.Warn().Err(err).Str("host", host.Hostname).Msg("failed to save host fingerprint")
|
|
continue
|
|
}
|
|
|
|
if oldFP == nil {
|
|
newCount++
|
|
log.Debug().
|
|
Str("type", "agent").
|
|
Str("host", host.Hostname).
|
|
Str("hash", newFP.Hash).
|
|
Msg("New fingerprint captured")
|
|
} else if newFP.HasSchemaChanged(oldFP) {
|
|
log.Debug().
|
|
Str("type", "agent").
|
|
Str("host", host.Hostname).
|
|
Int("old_schema", oldFP.SchemaVersion).
|
|
Int("new_schema", newFP.SchemaVersion).
|
|
Msg("Fingerprint schema updated")
|
|
} else if oldFP.Hash != newFP.Hash {
|
|
changedCount++
|
|
log.Info().
|
|
Str("type", "agent").
|
|
Str("host", host.Hostname).
|
|
Str("old_hash", oldFP.Hash).
|
|
Str("new_hash", newFP.Hash).
|
|
Msg("Fingerprint changed - discovery will run on next request")
|
|
}
|
|
}
|
|
|
|
// Process Kubernetes pods
|
|
for _, cluster := range snap.KubernetesClusters {
|
|
for _, pod := range cluster.Pods {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
// Generate new fingerprint
|
|
newFP := GenerateK8sPodFingerprint(cluster.ID, &pod)
|
|
fpKey := "k8s:" + cluster.ID + ":" + pod.Namespace + "/" + pod.Name
|
|
|
|
// Get previous fingerprint
|
|
oldFP, err := s.store.GetFingerprint(fpKey)
|
|
if err != nil {
|
|
log.Warn().
|
|
Err(err).
|
|
Str("resource_id", fpKey).
|
|
Str("pod", pod.Name).
|
|
Str("namespace", pod.Namespace).
|
|
Msg("Failed to load previous K8s pod fingerprint")
|
|
}
|
|
|
|
// Update the fingerprint's ResourceID to include prefix for storage
|
|
newFP.ResourceID = fpKey
|
|
|
|
// Save new fingerprint
|
|
if err := s.store.SaveFingerprint(newFP); err != nil {
|
|
log.Warn().Err(err).Str("pod", pod.Name).Str("namespace", pod.Namespace).Msg("failed to save K8s pod fingerprint")
|
|
continue
|
|
}
|
|
|
|
// Check if this is new or changed
|
|
if oldFP == nil {
|
|
newCount++
|
|
log.Debug().
|
|
Str("type", "k8s").
|
|
Str("name", pod.Name).
|
|
Str("namespace", pod.Namespace).
|
|
Str("cluster", cluster.Name).
|
|
Str("hash", newFP.Hash).
|
|
Msg("New fingerprint captured")
|
|
} else if newFP.HasSchemaChanged(oldFP) {
|
|
log.Debug().
|
|
Str("type", "k8s").
|
|
Str("name", pod.Name).
|
|
Str("namespace", pod.Namespace).
|
|
Str("cluster", cluster.Name).
|
|
Int("old_schema", oldFP.SchemaVersion).
|
|
Int("new_schema", newFP.SchemaVersion).
|
|
Msg("Fingerprint schema updated")
|
|
} else if oldFP.Hash != newFP.Hash {
|
|
changedCount++
|
|
log.Info().
|
|
Str("type", "k8s").
|
|
Str("name", pod.Name).
|
|
Str("namespace", pod.Namespace).
|
|
Str("cluster", cluster.Name).
|
|
Str("old_hash", oldFP.Hash).
|
|
Str("new_hash", newFP.Hash).
|
|
Msg("Fingerprint changed - discovery will run on next request")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update last scan time
|
|
s.store.SetLastFingerprintScan(time.Now())
|
|
|
|
if newCount > 0 || changedCount > 0 {
|
|
log.Info().
|
|
Int("new", newCount).
|
|
Int("changed", changedCount).
|
|
Int("total", s.store.GetFingerprintCount()).
|
|
Msg("Fingerprint collection complete")
|
|
} else {
|
|
log.Debug().
|
|
Int("total", s.store.GetFingerprintCount()).
|
|
Msg("Fingerprint collection complete - no changes")
|
|
}
|
|
|
|
// Cleanup orphaned data (fingerprints/discoveries for removed resources)
|
|
s.cleanupOrphanedData(snap)
|
|
}
|
|
|
|
// processOrphanedDataFingerprint processes fingerprints for LXC containers and VMs.
|
|
func (s *Service) processFingerprint(
|
|
ctx context.Context,
|
|
generateFP interface{},
|
|
resourceType string,
|
|
prefix string,
|
|
items interface{},
|
|
) (int, int) {
|
|
var changedCount, newCount int
|
|
|
|
fpFuncVal := reflect.ValueOf(generateFP)
|
|
if fpFuncVal.Kind() != reflect.Func {
|
|
return 0, 0
|
|
}
|
|
|
|
v := reflect.ValueOf(items)
|
|
if v.Kind() != reflect.Slice {
|
|
return 0, 0
|
|
}
|
|
|
|
for i := 0; i < v.Len(); i++ {
|
|
select {
|
|
case <-ctx.Done():
|
|
return newCount, changedCount
|
|
default:
|
|
}
|
|
|
|
// Generators take *Container / *VM. Reach through .Addr() — going via
|
|
// .Interface() drops addressability and reflect.Call panics on the type
|
|
// mismatch.
|
|
elem := v.Index(i)
|
|
if !elem.CanAddr() {
|
|
continue
|
|
}
|
|
|
|
node := elem.FieldByName("Node").String()
|
|
name := elem.FieldByName("Name").String()
|
|
vmid := elem.FieldByName("VMID").Int()
|
|
|
|
args := []reflect.Value{reflect.ValueOf(node), elem.Addr()}
|
|
newFP := fpFuncVal.Call(args)[0].Interface().(*ContainerFingerprint)
|
|
fpKey := prefix + node + ":" + newFP.ResourceID
|
|
|
|
oldFP, err := s.store.GetFingerprint(fpKey)
|
|
if err != nil {
|
|
log.Warn().
|
|
Err(err).
|
|
Str("resource_id", fpKey).
|
|
Str(resourceType, name).
|
|
Int("vmid", int(vmid)).
|
|
Msg("Failed to load previous " + resourceType + " fingerprint")
|
|
}
|
|
|
|
newFP.ResourceID = fpKey
|
|
|
|
if err := s.store.SaveFingerprint(newFP); err != nil {
|
|
log.Warn().Err(err).Str(resourceType, name).Msg("failed to save " + resourceType + " fingerprint")
|
|
continue
|
|
}
|
|
|
|
if oldFP == nil {
|
|
newCount++
|
|
log.Debug().
|
|
Str("type", resourceType).
|
|
Str("name", name).
|
|
Int("vmid", int(vmid)).
|
|
Str("hash", newFP.Hash).
|
|
Msg("New fingerprint captured")
|
|
} else if newFP.HasSchemaChanged(oldFP) {
|
|
log.Debug().
|
|
Str("type", resourceType).
|
|
Str("name", name).
|
|
Int("vmid", int(vmid)).
|
|
Int("old_schema", oldFP.SchemaVersion).
|
|
Int("new_schema", newFP.SchemaVersion).
|
|
Msg("Fingerprint schema updated")
|
|
} else if oldFP.Hash != newFP.Hash {
|
|
changedCount++
|
|
log.Info().
|
|
Str("type", resourceType).
|
|
Str("name", name).
|
|
Int("vmid", int(vmid)).
|
|
Str("old_hash", oldFP.Hash).
|
|
Str("new_hash", newFP.Hash).
|
|
Msg("Fingerprint changed - discovery will run on next request")
|
|
}
|
|
}
|
|
|
|
return newCount, changedCount
|
|
}
|
|
|
|
// cleanupOrphanedData removes fingerprints and discoveries for resources that no longer exist.
|
|
func (s *Service) cleanupOrphanedData(snap StateSnapshot) {
|
|
// Safety check: Don't cleanup if state appears empty
|
|
// This prevents catastrophic deletion if state provider has an error
|
|
totalResources := len(snap.Containers) + len(snap.VMs) + len(snap.KubernetesClusters) + len(snap.Hosts)
|
|
for _, host := range snap.DockerHosts {
|
|
totalResources += len(host.Containers)
|
|
}
|
|
if totalResources == 0 {
|
|
log.Debug().Msg("skipping orphaned data cleanup - state is empty (may be an error)")
|
|
return
|
|
}
|
|
|
|
// Build set of current resource IDs
|
|
currentIDs := make(map[string]bool)
|
|
|
|
// Docker containers
|
|
for _, host := range snap.DockerHosts {
|
|
for _, container := range host.Containers {
|
|
fpKey := "docker:" + host.AgentID + ":" + container.Name
|
|
currentIDs[fpKey] = true
|
|
}
|
|
}
|
|
|
|
// System containers
|
|
for _, ct := range snap.Containers {
|
|
fpKey := "system-container:" + ct.Node + ":" + strconv.Itoa(ct.VMID)
|
|
currentIDs[fpKey] = true
|
|
}
|
|
|
|
// VMs
|
|
for _, vm := range snap.VMs {
|
|
fpKey := "vm:" + vm.Node + ":" + strconv.Itoa(vm.VMID)
|
|
currentIDs[fpKey] = true
|
|
}
|
|
|
|
// Kubernetes pods
|
|
for _, cluster := range snap.KubernetesClusters {
|
|
for _, pod := range cluster.Pods {
|
|
fpKey := "k8s:" + cluster.ID + ":" + pod.Namespace + "/" + pod.Name
|
|
currentIDs[fpKey] = true
|
|
}
|
|
}
|
|
|
|
// Agent hosts and PVE nodes. Host discoveries are keyed canonically by
|
|
// agent UUID (agent:<id>:<id>) but legacy/alias records may be keyed by
|
|
// hostname or node name — keep every form so live hosts are never swept
|
|
// as orphans.
|
|
addAgentKey := func(id string) {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return
|
|
}
|
|
currentIDs[string(ResourceTypeAgent)+":"+id+":"+id] = true
|
|
// Pre-v6 records were stored under the legacy "host" alias and IDs
|
|
// are persisted verbatim, so keep that form too.
|
|
currentIDs[string(legacyHostAlias)+":"+id+":"+id] = true
|
|
}
|
|
for _, host := range snap.Hosts {
|
|
addAgentKey(host.ID)
|
|
addAgentKey(host.Hostname)
|
|
}
|
|
for _, node := range snap.Nodes {
|
|
addAgentKey(node.ID)
|
|
addAgentKey(node.Name)
|
|
}
|
|
|
|
// Run cleanup
|
|
fpRemoved := s.store.CleanupOrphanedFingerprints(currentIDs)
|
|
discRemoved := s.store.CleanupOrphanedDiscoveries(currentIDs)
|
|
|
|
if fpRemoved > 0 || discRemoved > 0 {
|
|
log.Info().
|
|
Int("fingerprints_removed", fpRemoved).
|
|
Int("discoveries_removed", discRemoved).
|
|
Msg("Cleaned up orphaned data")
|
|
}
|
|
}
|
|
|
|
// discoverDockerContainers runs discovery on Docker containers using metadata.
|
|
// Automatically runs deep scans when the shallow scan results are incomplete or low-confidence.
|
|
func (s *Service) discoverDockerContainers(ctx context.Context, hosts []DockerHost) {
|
|
s.mu.RLock()
|
|
analyzer := s.aiAnalyzer
|
|
s.mu.RUnlock()
|
|
|
|
if analyzer == nil {
|
|
log.Debug().Msg("aI analyzer not set, skipping Docker discovery")
|
|
return
|
|
}
|
|
|
|
for _, host := range hosts {
|
|
for _, container := range host.Containers {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
// Build resource ID
|
|
id := MakeResourceID(ResourceTypeDocker, host.AgentID, container.Name)
|
|
|
|
// Check if we already have a recent discovery
|
|
if !s.store.NeedsRefresh(id, s.cacheExpiry) {
|
|
continue
|
|
}
|
|
|
|
// Check existing discovery to see if it needs a deep scan
|
|
existing, err := s.store.Get(id)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("id", id).Msg("Failed to load existing discovery before shallow analysis")
|
|
}
|
|
|
|
// Analyze using metadata (shallow discovery)
|
|
discovery := s.analyzeDockerContainer(ctx, analyzer, container, host)
|
|
if discovery != nil {
|
|
// Smart auto deep scan: enhance if discovery is incomplete or low-confidence
|
|
// Also deep scan if there's no existing discovery (first time)
|
|
if s.scanner != nil && s.IsCommandScanningEnabled() && (existing == nil || s.needsDeepScan(discovery)) {
|
|
log.Info().
|
|
Str("id", id).
|
|
Float64("confidence", discovery.Confidence).
|
|
Str("serviceType", discovery.ServiceType).
|
|
Bool("firstDiscovery", existing == nil).
|
|
Msg("Auto deep scan triggered due to incomplete discovery")
|
|
discovery = s.enhanceWithDeepScan(ctx, discovery, host)
|
|
}
|
|
|
|
// Suggest web interface URL and availability probe using Docker host hostname
|
|
discovery.SuggestedURL = SuggestWebURL(discovery, host.Hostname)
|
|
discovery.SuggestedAvailabilityProbe = SuggestAvailabilityProbe(discovery, host.Hostname)
|
|
|
|
if err := s.store.Save(discovery); err != nil {
|
|
log.Warn().Err(err).Str("id", id).Msg("failed to save discovery")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// enhanceWithDeepScan runs a deep scan and merges the results into the discovery.
|
|
func (s *Service) enhanceWithDeepScan(ctx context.Context, discovery *ResourceDiscovery, host DockerHost) *ResourceDiscovery {
|
|
s.mu.RLock()
|
|
timeout := s.deepScanTimeout
|
|
analyzer := s.aiAnalyzer
|
|
s.mu.RUnlock()
|
|
timeout = normalizeDeepScanTimeout(timeout)
|
|
|
|
if s.scanner == nil || analyzer == nil || !s.IsCommandScanningEnabled() {
|
|
return discovery
|
|
}
|
|
|
|
// Create a timeout context for the deep scan
|
|
scanCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
req := DiscoveryRequest{
|
|
ResourceType: discovery.ResourceType,
|
|
ResourceID: discovery.ResourceID,
|
|
TargetID: discovery.TargetID,
|
|
Hostname: discovery.Hostname,
|
|
}
|
|
|
|
scanResult, err := s.scanner.Scan(scanCtx, req)
|
|
if err != nil {
|
|
log.Debug().Err(err).Str("id", discovery.ID).Msg("deep scan failed during background discovery")
|
|
return discovery
|
|
}
|
|
|
|
if len(scanResult.CommandOutputs) == 0 {
|
|
return discovery
|
|
}
|
|
|
|
// Build analysis request with command outputs
|
|
targetID := canonicalDiscoveryTargetID(discovery)
|
|
analysisReq := AIAnalysisRequest{
|
|
ResourceType: discovery.ResourceType,
|
|
ResourceID: discovery.ResourceID,
|
|
TargetID: targetID,
|
|
Hostname: discovery.Hostname,
|
|
CommandOutputs: scanResult.CommandOutputs,
|
|
}
|
|
|
|
// Add metadata if available
|
|
if s.hasStateAccess() {
|
|
analysisReq.Metadata = s.getResourceMetadata(req)
|
|
}
|
|
|
|
// Build prompt and analyze
|
|
prompt := s.buildDeepAnalysisPrompt(analysisReq)
|
|
response, err := analyzer.AnalyzeForDiscovery(scanCtx, prompt)
|
|
if err != nil {
|
|
log.Debug().Err(err).Str("id", discovery.ID).Msg("deep analysis failed during background discovery")
|
|
return discovery
|
|
}
|
|
|
|
result := s.parseAIResponse(response)
|
|
if result == nil {
|
|
return discovery
|
|
}
|
|
|
|
// Merge results - deep scan results take precedence for non-empty fields
|
|
if result.ServiceType != "" && result.ServiceType != "unknown" {
|
|
discovery.ServiceType = result.ServiceType
|
|
}
|
|
if result.ServiceName != "" {
|
|
discovery.ServiceName = result.ServiceName
|
|
}
|
|
if result.ServiceVersion != "" {
|
|
discovery.ServiceVersion = result.ServiceVersion
|
|
}
|
|
if result.Category != "" && result.Category != CategoryUnknown {
|
|
discovery.Category = result.Category
|
|
}
|
|
if result.CLIAccess != "" {
|
|
discovery.CLIAccess = s.formatCLIAccess(discovery.ResourceType, discovery.ResourceID, result.CLIAccess)
|
|
}
|
|
if len(result.Facts) > 0 {
|
|
discovery.Facts = result.Facts
|
|
}
|
|
if len(result.ConfigPaths) > 0 {
|
|
discovery.ConfigPaths = result.ConfigPaths
|
|
}
|
|
if len(result.DataPaths) > 0 {
|
|
discovery.DataPaths = result.DataPaths
|
|
}
|
|
if len(result.LogPaths) > 0 {
|
|
discovery.LogPaths = result.LogPaths
|
|
}
|
|
if len(result.Ports) > 0 {
|
|
discovery.Ports = result.Ports
|
|
}
|
|
if result.Confidence > discovery.Confidence {
|
|
discovery.Confidence = result.Confidence
|
|
}
|
|
if result.Reasoning != "" {
|
|
discovery.AIReasoning = result.Reasoning
|
|
}
|
|
|
|
// Store raw command outputs
|
|
discovery.RawCommandOutput = scanResult.CommandOutputs
|
|
discovery.ScanDuration = scanResult.CompletedAt.Sub(scanResult.StartedAt).Milliseconds()
|
|
discovery.UpdatedAt = time.Now()
|
|
|
|
// Parse docker_mounts if present (for LXCs/VMs running Docker)
|
|
if dockerMountsOutput, ok := scanResult.CommandOutputs["docker_mounts"]; ok {
|
|
discovery.DockerMounts = parseDockerMounts(dockerMountsOutput)
|
|
if len(discovery.DockerMounts) > 0 {
|
|
log.Debug().
|
|
Str("id", discovery.ID).
|
|
Int("mountCount", len(discovery.DockerMounts)).
|
|
Msg("Parsed Docker bind mounts from discovery")
|
|
}
|
|
}
|
|
|
|
log.Info().
|
|
Str("id", discovery.ID).
|
|
Int("commandOutputs", len(scanResult.CommandOutputs)).
|
|
Int("dockerMounts", len(discovery.DockerMounts)).
|
|
Dur("scanDuration", scanResult.CompletedAt.Sub(scanResult.StartedAt)).
|
|
Msg("Enhanced discovery with deep scan")
|
|
|
|
return discovery
|
|
}
|
|
|
|
// analyzeDockerContainer analyzes a Docker container using AI.
|
|
func (s *Service) analyzeDockerContainer(ctx context.Context, analyzer AIAnalyzer, c DockerContainer, host DockerHost) *ResourceDiscovery {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
// Check cache first (per-image timestamp)
|
|
s.cacheMu.RLock()
|
|
entry, found := s.analysisCache[c.Image]
|
|
cacheValid := found && time.Since(entry.cachedAt) < s.cacheExpiry
|
|
s.cacheMu.RUnlock()
|
|
|
|
var result *AIAnalysisResponse
|
|
|
|
if cacheValid {
|
|
result = entry.result
|
|
} else {
|
|
// Build prompt for AI analysis
|
|
prompt := s.buildMetadataAnalysisPrompt(c, host)
|
|
|
|
analyzeCtx, cancel := context.WithTimeout(ctx, s.aiAnalysisTimeout)
|
|
defer cancel()
|
|
|
|
response, err := analyzer.AnalyzeForDiscovery(analyzeCtx, prompt)
|
|
if err != nil {
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
|
log.Warn().
|
|
Err(err).
|
|
Str("container", c.Name).
|
|
Dur("timeout", s.aiAnalysisTimeout).
|
|
Msg("AI metadata analysis timed out")
|
|
return nil
|
|
}
|
|
log.Warn().Err(err).Str("container", c.Name).Msg("aI analysis failed")
|
|
return nil
|
|
}
|
|
|
|
result = s.parseAIResponse(response)
|
|
if result == nil {
|
|
log.Warn().Str("container", c.Name).Msg("failed to parse AI response")
|
|
return nil
|
|
}
|
|
|
|
// Cache the result with its own timestamp
|
|
s.cacheMu.Lock()
|
|
s.analysisCache[c.Image] = &analysisCacheEntry{
|
|
result: result,
|
|
cachedAt: time.Now(),
|
|
}
|
|
s.cacheMu.Unlock()
|
|
}
|
|
|
|
// Skip unknown/low-confidence results
|
|
if result.ServiceType == "unknown" || result.Confidence < 0.5 {
|
|
return nil
|
|
}
|
|
|
|
// Build CLI access string
|
|
cliAccess := result.CLIAccess
|
|
if cliAccess != "" {
|
|
cliAccess = strings.ReplaceAll(cliAccess, "{container}", c.Name)
|
|
}
|
|
|
|
// Extract ports
|
|
var ports []PortInfo
|
|
for _, p := range c.Ports {
|
|
ports = append(ports, PortInfo{
|
|
Port: p.PrivatePort,
|
|
Protocol: p.Protocol,
|
|
Address: fmt.Sprintf(":%d", p.PublicPort),
|
|
})
|
|
}
|
|
|
|
return &ResourceDiscovery{
|
|
ID: MakeResourceID(ResourceTypeDocker, host.AgentID, c.Name),
|
|
ResourceType: ResourceTypeDocker,
|
|
ResourceID: c.Name,
|
|
TargetID: host.AgentID,
|
|
Hostname: host.Hostname,
|
|
ServiceType: result.ServiceType,
|
|
ServiceName: result.ServiceName,
|
|
ServiceVersion: result.ServiceVersion,
|
|
Category: result.Category,
|
|
CLIAccess: cliAccess,
|
|
DiscoveryEngineVersion: DiscoveryEngineVersion,
|
|
Facts: result.Facts,
|
|
ConfigPaths: result.ConfigPaths,
|
|
DataPaths: result.DataPaths,
|
|
LogPaths: result.LogPaths,
|
|
Ports: ports,
|
|
Confidence: result.Confidence,
|
|
AIReasoning: result.Reasoning,
|
|
DiscoveredAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
}
|
|
|
|
// DiscoverResource performs deep discovery on a specific resource.
|
|
// Uses fingerprint-based detection to avoid unnecessary AI calls:
|
|
// - Returns cached discovery if fingerprint hasn't changed
|
|
// - Runs discovery only when fingerprint changed or discovery is too old
|
|
// - Prevents duplicate concurrent discoveries for the same resource
|
|
func (s *Service) DiscoverResource(ctx context.Context, req DiscoveryRequest) (*ResourceDiscovery, error) {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
req = normalizeDiscoveryRequestAliases(req)
|
|
if err := ValidateCanonicalDiscoveryResourceType(req.ResourceType); err != nil {
|
|
return nil, fmt.Errorf("discover resource %q: %w", req.ResourceType, err)
|
|
}
|
|
|
|
originalReq := req
|
|
aliasIDs := make([]string, 0, 2)
|
|
if req.TargetID != "" && req.ResourceID != "" {
|
|
aliasIDs = append(aliasIDs, MakeResourceID(req.ResourceType, req.TargetID, req.ResourceID))
|
|
}
|
|
req = s.normalizeDiscoveryRequest(req, &aliasIDs)
|
|
|
|
resourceID := MakeResourceID(req.ResourceType, req.TargetID, req.ResourceID)
|
|
|
|
// Get current fingerprint (if available)
|
|
// Fingerprint key matches the resource ID format: type:scope:id
|
|
currentFP, err := s.store.GetFingerprint(resourceID)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("id", resourceID).Msg("Failed to load current fingerprint; continuing without fingerprint check")
|
|
}
|
|
|
|
// Get existing discovery
|
|
existing, err := s.store.Get(resourceID)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("id", resourceID).Msg("Failed to load existing discovery; running fresh discovery")
|
|
}
|
|
|
|
// Determine if we need to run discovery
|
|
needsDiscovery := false
|
|
reason := ""
|
|
|
|
if req.Force {
|
|
needsDiscovery = true
|
|
reason = "forced"
|
|
} else if existing == nil {
|
|
needsDiscovery = true
|
|
reason = "no existing discovery"
|
|
} else if currentFP != nil && existing.Fingerprint != currentFP.Hash {
|
|
// Fingerprint hash differs - check if it's just a schema version change
|
|
if existing.FingerprintSchemaVersion != 0 && existing.FingerprintSchemaVersion != currentFP.SchemaVersion {
|
|
// Schema changed but container didn't - don't trigger rediscovery
|
|
// This prevents mass rediscovery when we upgrade the fingerprint algorithm
|
|
log.Debug().
|
|
Str("id", resourceID).
|
|
Int("old_schema", existing.FingerprintSchemaVersion).
|
|
Int("new_schema", currentFP.SchemaVersion).
|
|
Msg("Fingerprint schema changed, but not triggering rediscovery")
|
|
} else {
|
|
// Same schema version, different hash = real container change
|
|
needsDiscovery = true
|
|
reason = "fingerprint changed"
|
|
}
|
|
} else if time.Since(existing.DiscoveredAt) > s.maxDiscoveryAge {
|
|
needsDiscovery = true
|
|
reason = "discovery too old"
|
|
} else if s.discoveryNeedsKnownServiceRepair(existing, req) {
|
|
needsDiscovery = true
|
|
reason = "known service identity repair"
|
|
}
|
|
|
|
// Return cached discovery if still valid
|
|
if !needsDiscovery && existing != nil {
|
|
s.upgradeCLIAccessIfNeeded(existing)
|
|
log.Debug().Str("id", resourceID).Msg("discovery still valid, returning cached")
|
|
return existing, nil
|
|
}
|
|
|
|
// Check for duplicate concurrent discovery requests
|
|
s.inProgressMu.Lock()
|
|
if inProg, ok := s.inProgress[resourceID]; ok {
|
|
// Discovery already in progress - wait for it
|
|
s.inProgressMu.Unlock()
|
|
log.Debug().Str("id", resourceID).Msg("discovery already in progress, waiting for result")
|
|
|
|
select {
|
|
case <-inProg.done:
|
|
return inProg.result, inProg.err
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
|
|
// Claim this discovery slot
|
|
inProg := &discoveryInProgress{
|
|
done: make(chan struct{}),
|
|
}
|
|
s.inProgress[resourceID] = inProg
|
|
s.inProgressMu.Unlock()
|
|
|
|
// Ensure we clean up and notify waiters when done
|
|
defer func() {
|
|
close(inProg.done)
|
|
s.inProgressMu.Lock()
|
|
delete(s.inProgress, resourceID)
|
|
s.inProgressMu.Unlock()
|
|
}()
|
|
|
|
log.Info().Str("id", resourceID).Str("reason", reason).Msg("running discovery")
|
|
|
|
s.mu.RLock()
|
|
analyzer := s.aiAnalyzer
|
|
s.mu.RUnlock()
|
|
|
|
if analyzer == nil {
|
|
inProg.err = fmt.Errorf("AI analyzer not configured")
|
|
return nil, inProg.err
|
|
}
|
|
|
|
// Run deep scan if scanner is available
|
|
var scanResult *ScanResult
|
|
var scanError error
|
|
if s.scanner != nil && s.IsCommandScanningEnabled() {
|
|
scanResult, scanError = s.scanner.Scan(ctx, req)
|
|
if scanError != nil {
|
|
log.Warn().
|
|
Err(scanError).
|
|
Str("id", resourceID).
|
|
Str("resource_type", string(req.ResourceType)).
|
|
Msg("Deep scan failed, falling back to metadata-only analysis. For full discovery, ensure the host agent is connected with commands enabled.")
|
|
}
|
|
}
|
|
|
|
// Build analysis request
|
|
analysisReq := AIAnalysisRequest{
|
|
ResourceType: req.ResourceType,
|
|
ResourceID: req.ResourceID,
|
|
TargetID: req.TargetID,
|
|
Hostname: req.Hostname,
|
|
}
|
|
|
|
if scanResult != nil {
|
|
analysisReq.CommandOutputs = scanResult.CommandOutputs
|
|
}
|
|
|
|
// Add metadata if available
|
|
if s.hasStateAccess() {
|
|
analysisReq.Metadata = s.getResourceMetadata(req)
|
|
if analysisReq.Hostname == "" {
|
|
analysisReq.Hostname = stringMetadataValue(analysisReq.Metadata, "name", "hostname", "display_name")
|
|
}
|
|
}
|
|
|
|
// When a resource type relies on command-execution evidence but the scan
|
|
// produced none (e.g. the host agent has "Pulse Commands" disabled), do NOT
|
|
// ask the model to identify the service. With only metadata it confabulates
|
|
// confident, false identities — inventing facts with fabricated command
|
|
// sources (a metadata-only scan once "identified" Pi-hole inside an ESPHome
|
|
// LXC at 0.95 confidence). Abstain instead; deterministic metadata identity
|
|
// is still applied below via applyKnownServiceIdentity.
|
|
// Abstain only when a command scan was actually attempted (scanner present
|
|
// and command scanning enabled) for a workload that needs command evidence,
|
|
// yet produced no output — i.e. we expected to look inside the workload and
|
|
// couldn't (e.g. the host agent rejected exec). In that state the model has
|
|
// nothing real to go on and confabulates; "not determined" is the honest
|
|
// answer. Deployments without command scanning keep the metadata-only path.
|
|
commandScanAttempted := s.scanner != nil && s.IsCommandScanningEnabled()
|
|
metadataOnly := resourceIdentityNeedsCommandEvidence(req.ResourceType) &&
|
|
commandScanAttempted &&
|
|
len(analysisReq.CommandOutputs) == 0
|
|
|
|
var result *AIAnalysisResponse
|
|
if identity, evidence, ok := inferSurfaceIdentity(req, analysisReq.Metadata); ok {
|
|
// Fast surface path: the resource name clearly identifies a known
|
|
// service, so skip the (slow) model entirely. Identity is the surface;
|
|
// depth comes from the Assistant's own knowledge plus on-demand commands.
|
|
result = surfaceIdentityResponse(identity, evidence)
|
|
s.broadcastProgress(&DiscoveryProgress{
|
|
ResourceID: resourceID,
|
|
Status: DiscoveryStatusRunning,
|
|
CurrentStep: "Identified from resource name (fast path)",
|
|
PercentComplete: 90,
|
|
})
|
|
} else if identity, evidence, ok := inferSurfaceIdentityFromPorts(analysisReq.CommandOutputs["listening_ports"]); ok {
|
|
// Second fast path: an un-named workload often exposes a distinctive
|
|
// listening port that identifies it (8123→HA, 32400→Plex). Skip the
|
|
// model here too — a distinctive port is a high-confidence signal, and
|
|
// the configured model may be slow enough to time out otherwise.
|
|
result = surfaceIdentityResponse(identity, evidence)
|
|
s.broadcastProgress(&DiscoveryProgress{
|
|
ResourceID: resourceID,
|
|
Status: DiscoveryStatusRunning,
|
|
CurrentStep: "Identified from listening port (fast path)",
|
|
PercentComplete: 90,
|
|
})
|
|
} else if metadataOnly {
|
|
result = metadataOnlyDiscoveryAbstention()
|
|
} else {
|
|
// Build prompt and analyze
|
|
prompt := s.buildDeepAnalysisPrompt(analysisReq)
|
|
|
|
// Broadcast progress: AI analysis starting
|
|
s.broadcastProgress(&DiscoveryProgress{
|
|
ResourceID: resourceID,
|
|
Status: DiscoveryStatusRunning,
|
|
CurrentStep: "Analyzing discovery evidence with the selected model...",
|
|
PercentComplete: 75,
|
|
})
|
|
|
|
analyzeCtx, cancel := context.WithTimeout(ctx, s.aiAnalysisTimeout)
|
|
defer cancel()
|
|
|
|
response, err := analyzer.AnalyzeForDiscovery(analyzeCtx, prompt)
|
|
if err != nil {
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
|
inProg.err = fmt.Errorf("AI analysis timed out after %s", s.aiAnalysisTimeout)
|
|
return nil, inProg.err
|
|
}
|
|
inProg.err = fmt.Errorf("AI analysis failed: %w", err)
|
|
return nil, inProg.err
|
|
}
|
|
|
|
result = s.parseAIResponse(response)
|
|
if result == nil {
|
|
// Truncate response for error message
|
|
truncated := response
|
|
if len(truncated) > 500 {
|
|
truncated = truncated[:500] + "..."
|
|
}
|
|
inProg.err = fmt.Errorf("failed to parse AI response: %s", truncated)
|
|
return nil, inProg.err
|
|
}
|
|
}
|
|
|
|
// Resolve hostname from metadata if not provided in request
|
|
hostname := analysisReq.Hostname
|
|
if hostname == "" && analysisReq.Metadata != nil {
|
|
if name, ok := analysisReq.Metadata["name"].(string); ok && name != "" {
|
|
hostname = name
|
|
}
|
|
}
|
|
|
|
agentID := ""
|
|
if req.ResourceType == ResourceTypeAgent {
|
|
agentID = req.TargetID
|
|
}
|
|
|
|
// Build discovery result
|
|
discovery := &ResourceDiscovery{
|
|
ID: resourceID,
|
|
ResourceType: req.ResourceType,
|
|
ResourceID: req.ResourceID,
|
|
TargetID: req.TargetID,
|
|
AgentID: agentID,
|
|
Hostname: hostname,
|
|
ServiceType: result.ServiceType,
|
|
ServiceName: result.ServiceName,
|
|
ServiceVersion: result.ServiceVersion,
|
|
Category: result.Category,
|
|
CLIAccess: s.formatCLIAccess(req.ResourceType, req.ResourceID, result.CLIAccess),
|
|
CLIAccessVersion: CLIAccessVersion,
|
|
DiscoveryEngineVersion: DiscoveryEngineVersion,
|
|
Facts: result.Facts,
|
|
ConfigPaths: result.ConfigPaths,
|
|
DataPaths: result.DataPaths,
|
|
LogPaths: result.LogPaths,
|
|
Ports: result.Ports,
|
|
Confidence: result.Confidence,
|
|
AIReasoning: result.Reasoning,
|
|
DiscoveredAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
// Store fingerprint with discovery
|
|
if currentFP != nil {
|
|
discovery.Fingerprint = currentFP.Hash
|
|
discovery.FingerprintedAt = currentFP.GeneratedAt
|
|
discovery.FingerprintSchemaVersion = currentFP.SchemaVersion
|
|
}
|
|
|
|
if scanResult != nil {
|
|
discovery.RawCommandOutput = scanResult.CommandOutputs
|
|
discovery.ScanDuration = scanResult.CompletedAt.Sub(scanResult.StartedAt).Milliseconds()
|
|
|
|
// Parse docker_mounts if present (for LXCs/VMs running Docker)
|
|
if dockerMountsOutput, ok := scanResult.CommandOutputs["docker_mounts"]; ok {
|
|
discovery.DockerMounts = parseDockerMounts(dockerMountsOutput)
|
|
if len(discovery.DockerMounts) > 0 {
|
|
log.Debug().
|
|
Str("id", discovery.ID).
|
|
Int("mountCount", len(discovery.DockerMounts)).
|
|
Msg("Parsed Docker bind mounts from on-demand discovery")
|
|
}
|
|
}
|
|
} else if scanError != nil && !metadataOnly {
|
|
// Add note to reasoning when we couldn't run commands. Skipped when we
|
|
// abstained (metadataOnly), since the abstention reasoning already
|
|
// explains the missing-commands state without duplication.
|
|
metadataNote := "[Note: Discovery was limited to metadata-only analysis because command execution was unavailable. "
|
|
if strings.Contains(scanError.Error(), "no connected agent") {
|
|
metadataNote += "To enable full discovery with command execution, ensure the host agent has 'Pulse Commands' enabled in Settings → Infrastructure.]"
|
|
} else {
|
|
metadataNote += "Error: " + scanError.Error() + "]"
|
|
}
|
|
if discovery.AIReasoning != "" {
|
|
discovery.AIReasoning = metadataNote + " " + discovery.AIReasoning
|
|
} else {
|
|
discovery.AIReasoning = metadataNote
|
|
}
|
|
}
|
|
|
|
// Preserve user notes from existing discovery
|
|
if existing != nil {
|
|
discovery.UserNotes = existing.UserNotes
|
|
discovery.UserSecrets = existing.UserSecrets
|
|
if discovery.DiscoveredAt.IsZero() || existing.DiscoveredAt.Before(discovery.DiscoveredAt) {
|
|
discovery.DiscoveredAt = existing.DiscoveredAt
|
|
}
|
|
}
|
|
applyKnownServiceIdentity(discovery, req, analysisReq.Metadata, analysisReq.CommandOutputs)
|
|
|
|
// Access topology: if the identified service actually runs in a nested Docker
|
|
// container (e.g. Home Assistant Container inside an LXC), layer the access
|
|
// path so the Assistant enters the container instead of the bare guest shell.
|
|
// This is index-level "how to reach it" — only a probe can know it.
|
|
if req.ResourceType == ResourceTypeSystemContainer || req.ResourceType == ResourceTypeVM {
|
|
if probe, ok := analysisReq.CommandOutputs["nested_containers"]; ok {
|
|
if name := nestedContainerForService(probe, discovery.ServiceType, discovery.ServiceName); name != "" {
|
|
discovery.CLIAccess = withNestedDockerAccess(discovery.CLIAccess, name)
|
|
log.Debug().
|
|
Str("id", discovery.ID).
|
|
Str("container", name).
|
|
Msg("Service runs in a nested Docker container; layered access path")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Suggest web interface URL based on service type and external IP.
|
|
// If no URL can be inferred, capture diagnostics for logs and UI.
|
|
urlSuggestionDiagnostic := ""
|
|
urlSuggestionSourceCode := ""
|
|
urlSuggestionSourceDetail := ""
|
|
if !s.hasStateAccess() {
|
|
urlSuggestionDiagnostic = "ReadState unavailable"
|
|
} else {
|
|
externalIP := s.getResourceExternalIP(req)
|
|
if externalIP == "" {
|
|
urlSuggestionDiagnostic = "no host or IP candidate available"
|
|
} else {
|
|
primaryURL, primaryCode, primaryDetail := suggestWebURLWithReason(discovery, externalIP)
|
|
discovery.SuggestedURL = primaryURL
|
|
if discovery.SuggestedURL != "" {
|
|
urlSuggestionSourceCode = primaryCode
|
|
urlSuggestionSourceDetail = primaryDetail
|
|
} else {
|
|
fallbackURL, fallbackCode, fallbackDetail := s.suggestHostManagementURLWithReason(req, externalIP)
|
|
discovery.SuggestedURL = fallbackURL
|
|
if discovery.SuggestedURL != "" {
|
|
urlSuggestionSourceCode = fallbackCode
|
|
urlSuggestionSourceDetail = fallbackDetail
|
|
} else {
|
|
urlSuggestionDiagnostic = formatURLSuggestionDiagnostic(primaryCode, primaryDetail, fallbackCode, fallbackDetail)
|
|
log.Debug().
|
|
Str("id", discovery.ID).
|
|
Str("resource_type", string(req.ResourceType)).
|
|
Str("host", externalIP).
|
|
Str("primary_reason_code", primaryCode).
|
|
Str("fallback_reason_code", fallbackCode).
|
|
Str("diagnostic", urlSuggestionDiagnostic).
|
|
Msg("Unable to infer suggested URL")
|
|
}
|
|
}
|
|
}
|
|
// Suggest availability probe using the same external IP
|
|
if externalIP != "" {
|
|
discovery.SuggestedAvailabilityProbe = SuggestAvailabilityProbe(discovery, externalIP)
|
|
}
|
|
}
|
|
discovery.SuggestedURLSourceCode = urlSuggestionSourceCode
|
|
discovery.SuggestedURLSourceDetail = urlSuggestionSourceDetail
|
|
discovery.SuggestedURLDiagnostic = urlSuggestionDiagnostic
|
|
|
|
// Broadcast progress: Discovery complete
|
|
s.broadcastProgress(&DiscoveryProgress{
|
|
ResourceID: resourceID,
|
|
Status: DiscoveryStatusCompleted,
|
|
CurrentStep: "Discovery complete",
|
|
PercentComplete: 100,
|
|
})
|
|
|
|
// Save discovery
|
|
if err := s.store.Save(discovery); err != nil {
|
|
inProg.err = fmt.Errorf("failed to save discovery: %w", err)
|
|
return nil, inProg.err
|
|
}
|
|
s.cleanupAliasedDiscoveries(resourceID, aliasIDs)
|
|
|
|
// Store result for any waiting goroutines
|
|
inProg.result = discovery
|
|
if originalReq != req {
|
|
originalTargetID := canonicalRequestTargetID(originalReq)
|
|
log.Debug().
|
|
Str("original_id", MakeResourceID(originalReq.ResourceType, originalTargetID, originalReq.ResourceID)).
|
|
Str("canonical_id", resourceID).
|
|
Msg("Discovery request canonicalized")
|
|
}
|
|
return discovery, nil
|
|
}
|
|
|
|
// normalizeDiscoveryRequest resolves discovery aliases to a canonical target ID.
|
|
// This prevents duplicate discoveries for the same physical host under different IDs.
|
|
func (s *Service) normalizeDiscoveryRequest(req DiscoveryRequest, aliasIDs *[]string) DiscoveryRequest {
|
|
req = normalizeDiscoveryRequestAliases(req)
|
|
requestTargetID := req.TargetID
|
|
|
|
switch req.ResourceType {
|
|
case ResourceTypeVM, ResourceTypeSystemContainer:
|
|
// Proxmox guests are canonically keyed by node name + VMID. The browser
|
|
// action path addresses them by the linked agent UUID (the
|
|
// discoveryTarget.agentId action-authorization target); resolve that back
|
|
// to the hosting node so the stored/looked-up record key matches the
|
|
// background fingerprint loop and the Assistant prefetch (which already
|
|
// use node name). See canonicalizeProxmoxGuestDiscoveryTarget.
|
|
return s.canonicalizeProxmoxGuestDiscoveryTarget(req, requestTargetID)
|
|
case ResourceTypeAgent:
|
|
// Agent discovery is canonically keyed by the agent UUID; the logic below
|
|
// redirects a node-name/hostname target to its linked agent.
|
|
default:
|
|
return req
|
|
}
|
|
snap, ok := s.getSnapshot()
|
|
if !ok {
|
|
return req
|
|
}
|
|
addAlias := func(hostID, resourceID string) {
|
|
if hostID == "" || resourceID == "" {
|
|
return
|
|
}
|
|
id := MakeResourceID(ResourceTypeAgent, hostID, resourceID)
|
|
for _, existing := range *aliasIDs {
|
|
if existing == id {
|
|
return
|
|
}
|
|
}
|
|
*aliasIDs = append(*aliasIDs, id)
|
|
}
|
|
|
|
for _, host := range snap.Hosts {
|
|
if host.ID == requestTargetID || host.ID == req.ResourceID || host.Hostname == requestTargetID || host.Hostname == req.ResourceID || (req.Hostname != "" && host.Hostname == req.Hostname) {
|
|
addAlias(host.ID, host.ID)
|
|
addAlias(host.Hostname, host.Hostname)
|
|
if req.Hostname == "" {
|
|
req.Hostname = host.Hostname
|
|
}
|
|
req.TargetID = host.ID
|
|
req.ResourceID = host.ID
|
|
return req
|
|
}
|
|
}
|
|
|
|
for _, node := range snap.Nodes {
|
|
if node.Name == requestTargetID || node.Name == req.ResourceID || node.ID == requestTargetID || node.ID == req.ResourceID || (req.Hostname != "" && node.Name == req.Hostname) {
|
|
addAlias(node.Name, node.Name)
|
|
addAlias(node.ID, node.ID)
|
|
if req.Hostname == "" {
|
|
req.Hostname = node.Name
|
|
}
|
|
if node.LinkedAgentID != "" {
|
|
log.Info().
|
|
Str("from_target", requestTargetID).
|
|
Str("to_agent", node.LinkedAgentID).
|
|
Msg("Redirecting discovery scan to linked host agent")
|
|
addAlias(node.LinkedAgentID, node.LinkedAgentID)
|
|
req.TargetID = node.LinkedAgentID
|
|
req.ResourceID = node.LinkedAgentID
|
|
return req
|
|
}
|
|
req.TargetID = node.Name
|
|
req.ResourceID = node.Name
|
|
return req
|
|
}
|
|
}
|
|
|
|
return req
|
|
}
|
|
|
|
// canonicalizeProxmoxGuestDiscoveryTarget canonicalizes a Proxmox guest (VM /
|
|
// system-container) discovery request to the stable NODE-NAME key. Discovery
|
|
// records for guests are canonically keyed by node name + VMID — the form the
|
|
// background fingerprint loop and the Assistant prefetch already use. The browser
|
|
// action path, however, addresses guests by the linked agent UUID (the
|
|
// discoveryTarget.agentId action-authorization target). Left unnormalized, a
|
|
// UUID-targeted request stored and looked records up under a second, divergent
|
|
// key, so the resource drawer reported "not discovered" for a guest the
|
|
// background loop had already discovered. Resolve a UUID-form target back to its
|
|
// hosting node name; the caller has already registered the original (UUID-form)
|
|
// ID as an alias, so any record previously stored under it is still found by
|
|
// lookups and consolidated onto the canonical key on the next discovery run.
|
|
//
|
|
// The agent UUID is unchanged as the action target — this only governs the
|
|
// record key. Resolution prefers the node→linked-agent map, then falls back to
|
|
// the agent host's hostname when that hostname is itself a known node (a
|
|
// Proxmox-node agent reports hostname == node name, which is how the command
|
|
// executor already resolves targets).
|
|
func (s *Service) canonicalizeProxmoxGuestDiscoveryTarget(req DiscoveryRequest, requestTargetID string) DiscoveryRequest {
|
|
if strings.TrimSpace(requestTargetID) == "" {
|
|
return req
|
|
}
|
|
snap, ok := s.getSnapshot()
|
|
if !ok {
|
|
return req
|
|
}
|
|
|
|
nodeNames := make(map[string]struct{}, len(snap.Nodes))
|
|
nodeByLinkedAgent := make(map[string]string, len(snap.Nodes))
|
|
for _, node := range snap.Nodes {
|
|
if node.Name != "" {
|
|
nodeNames[node.Name] = struct{}{}
|
|
}
|
|
if node.LinkedAgentID != "" && node.Name != "" {
|
|
nodeByLinkedAgent[node.LinkedAgentID] = node.Name
|
|
}
|
|
}
|
|
|
|
// Already a node name — canonical, nothing to resolve.
|
|
if _, ok := nodeNames[requestTargetID]; ok {
|
|
return req
|
|
}
|
|
|
|
// The target is the linked agent UUID: resolve it to the hosting node name.
|
|
if nodeName := nodeByLinkedAgent[requestTargetID]; nodeName != "" {
|
|
req.TargetID = nodeName
|
|
if req.Hostname == "" {
|
|
req.Hostname = nodeName
|
|
}
|
|
return req
|
|
}
|
|
|
|
// Fallback: match the agent host by ID and adopt its hostname when that
|
|
// hostname is a known node.
|
|
for _, host := range snap.Hosts {
|
|
if host.ID == requestTargetID && host.Hostname != "" {
|
|
if _, ok := nodeNames[host.Hostname]; ok {
|
|
req.TargetID = host.Hostname
|
|
if req.Hostname == "" {
|
|
req.Hostname = host.Hostname
|
|
}
|
|
}
|
|
return req
|
|
}
|
|
}
|
|
|
|
return req
|
|
}
|
|
|
|
func (s *Service) cleanupAliasedDiscoveries(canonicalID string, aliasIDs []string) {
|
|
seen := make(map[string]struct{}, len(aliasIDs))
|
|
for _, aliasID := range aliasIDs {
|
|
if aliasID == "" || aliasID == canonicalID {
|
|
continue
|
|
}
|
|
if _, ok := seen[aliasID]; ok {
|
|
continue
|
|
}
|
|
seen[aliasID] = struct{}{}
|
|
if err := s.store.Delete(aliasID); err != nil {
|
|
log.Debug().Err(err).Str("id", aliasID).Msg("failed to clean up aliased discovery")
|
|
}
|
|
}
|
|
}
|
|
|
|
// getResourceMetadata retrieves metadata for a resource from the state.
|
|
func (s *Service) getResourceMetadata(req DiscoveryRequest) map[string]any {
|
|
snap, ok := s.getSnapshot()
|
|
if !ok {
|
|
return nil
|
|
}
|
|
metadata := make(map[string]any)
|
|
requestTargetID := canonicalRequestTargetID(req)
|
|
|
|
switch req.ResourceType {
|
|
case ResourceTypeSystemContainer:
|
|
for _, c := range snap.Containers {
|
|
if fmt.Sprintf("%d", c.VMID) == req.ResourceID && c.Node == requestTargetID {
|
|
metadata["name"] = c.Name
|
|
metadata["status"] = c.Status
|
|
metadata["vmid"] = c.VMID
|
|
metadata["os_template"] = c.OSTemplate
|
|
metadata["os_name"] = c.OSName
|
|
metadata["is_oci"] = c.IsOCI
|
|
if len(c.Tags) > 0 {
|
|
metadata["tags"] = c.Tags
|
|
}
|
|
break
|
|
}
|
|
}
|
|
case ResourceTypeVM:
|
|
for _, vm := range snap.VMs {
|
|
if fmt.Sprintf("%d", vm.VMID) == req.ResourceID && vm.Node == requestTargetID {
|
|
metadata["name"] = vm.Name
|
|
metadata["status"] = vm.Status
|
|
metadata["vmid"] = vm.VMID
|
|
metadata["os_name"] = vm.OSName
|
|
metadata["os_version"] = vm.OSVersion
|
|
if len(vm.Tags) > 0 {
|
|
metadata["tags"] = vm.Tags
|
|
}
|
|
break
|
|
}
|
|
}
|
|
case ResourceTypeDocker:
|
|
for _, host := range snap.DockerHosts {
|
|
if host.AgentID == requestTargetID || host.Hostname == requestTargetID {
|
|
for _, c := range host.Containers {
|
|
if c.Name == req.ResourceID {
|
|
metadata["name"] = c.Name
|
|
metadata["image"] = c.Image
|
|
metadata["status"] = c.Status
|
|
// Filter sensitive labels before sending to AI
|
|
metadata["labels"] = filterSensitiveLabels(c.Labels)
|
|
break
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
case ResourceTypeAgent:
|
|
for _, host := range snap.Hosts {
|
|
if host.ID == req.ResourceID || host.Hostname == req.ResourceID || host.ID == requestTargetID {
|
|
metadata["hostname"] = host.Hostname
|
|
metadata["display_name"] = host.DisplayName
|
|
metadata["platform"] = host.Platform
|
|
metadata["os_name"] = host.OSName
|
|
metadata["os_version"] = host.OSVersion
|
|
metadata["kernel_version"] = host.KernelVersion
|
|
metadata["architecture"] = host.Architecture
|
|
metadata["cpu_count"] = host.CPUCount
|
|
metadata["status"] = host.Status
|
|
if len(host.Tags) > 0 {
|
|
metadata["tags"] = host.Tags
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return metadata
|
|
}
|
|
|
|
func stringMetadataValue(metadata map[string]any, keys ...string) string {
|
|
for _, key := range keys {
|
|
value, ok := metadata[key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if text, ok := value.(string); ok {
|
|
if trimmed := strings.TrimSpace(text); trimmed != "" {
|
|
return trimmed
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// getResourceExternalIP retrieves the external IP address for a resource from the state.
|
|
// For system containers/VMs, this is the first IP from the Proxmox guest agent.
|
|
// For Docker containers, this is the Docker host's IP/hostname.
|
|
func (s *Service) getResourceExternalIP(req DiscoveryRequest) string {
|
|
snap, ok := s.getSnapshot()
|
|
if !ok {
|
|
return ""
|
|
}
|
|
requestTargetID := canonicalRequestTargetID(req)
|
|
|
|
switch req.ResourceType {
|
|
case ResourceTypeSystemContainer:
|
|
for _, c := range snap.Containers {
|
|
if fmt.Sprintf("%d", c.VMID) == req.ResourceID {
|
|
if len(c.IPAddresses) > 0 {
|
|
return c.IPAddresses[0]
|
|
}
|
|
if candidate := firstResourceHostnameCandidate(req.ResourceID, requestTargetID, c.Name, req.Hostname); candidate != "" {
|
|
return candidate
|
|
}
|
|
}
|
|
}
|
|
case ResourceTypeVM:
|
|
for _, vm := range snap.VMs {
|
|
if fmt.Sprintf("%d", vm.VMID) == req.ResourceID {
|
|
if len(vm.IPAddresses) > 0 {
|
|
return vm.IPAddresses[0]
|
|
}
|
|
if candidate := firstResourceHostnameCandidate(req.ResourceID, requestTargetID, vm.Name, req.Hostname); candidate != "" {
|
|
return candidate
|
|
}
|
|
return ""
|
|
}
|
|
}
|
|
case ResourceTypeDocker:
|
|
// For Docker containers, use the Docker host's hostname/IP
|
|
for _, host := range snap.DockerHosts {
|
|
if host.AgentID == requestTargetID || host.Hostname == requestTargetID {
|
|
// Use hostname if it looks like an IP, otherwise it's a hostname
|
|
return host.Hostname
|
|
}
|
|
}
|
|
case ResourceTypeDockerVM, ResourceTypeDockerSystemContainer:
|
|
// For Docker containers inside VMs/system containers, find the parent's IP
|
|
// The target ID contains the parent resource info.
|
|
for _, vm := range snap.VMs {
|
|
if fmt.Sprintf("%d", vm.VMID) == requestTargetID || vm.Name == requestTargetID {
|
|
if len(vm.IPAddresses) > 0 {
|
|
return vm.IPAddresses[0]
|
|
}
|
|
if candidate := firstResourceHostnameCandidate(req.ResourceID, requestTargetID, vm.Name, req.Hostname); candidate != "" {
|
|
return candidate
|
|
}
|
|
}
|
|
}
|
|
for _, c := range snap.Containers {
|
|
if fmt.Sprintf("%d", c.VMID) == requestTargetID || c.Name == requestTargetID {
|
|
if len(c.IPAddresses) > 0 {
|
|
return c.IPAddresses[0]
|
|
}
|
|
if candidate := firstResourceHostnameCandidate(req.ResourceID, requestTargetID, c.Name, req.Hostname); candidate != "" {
|
|
return candidate
|
|
}
|
|
}
|
|
}
|
|
case ResourceTypeAgent:
|
|
// Host-agent resources: prefer the reported hostname from state
|
|
for _, host := range snap.Hosts {
|
|
if host.ID == req.ResourceID || host.Hostname == req.ResourceID || host.ID == requestTargetID || host.Hostname == requestTargetID {
|
|
if isURLHostCandidate(host.Hostname) {
|
|
return host.Hostname
|
|
}
|
|
}
|
|
}
|
|
|
|
// Proxmox node resources routed through host discovery: fall back to node name
|
|
for _, node := range snap.Nodes {
|
|
if node.ID == req.ResourceID || node.Name == req.ResourceID || node.ID == requestTargetID || node.Name == requestTargetID {
|
|
if isURLHostCandidate(node.Name) {
|
|
return node.Name
|
|
}
|
|
}
|
|
}
|
|
|
|
// Last-resort fallback from request values (when state snapshot doesn't have a direct match yet)
|
|
if isURLHostCandidate(req.Hostname) {
|
|
return req.Hostname
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func isURLHostCandidate(value string) bool {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return false
|
|
}
|
|
// Reject obvious non-host labels (display names, paths, etc.)
|
|
if strings.ContainsAny(trimmed, " /\\") {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func firstResourceHostnameCandidate(resourceID, targetID string, candidates ...string) string {
|
|
resourceID = strings.TrimSpace(resourceID)
|
|
targetID = strings.TrimSpace(targetID)
|
|
for _, candidate := range candidates {
|
|
trimmed := strings.TrimSpace(candidate)
|
|
if trimmed == "" || trimmed == resourceID || trimmed == targetID {
|
|
continue
|
|
}
|
|
if isURLHostCandidate(trimmed) {
|
|
return trimmed
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func formatURLSuggestionDiagnostic(primaryCode, primaryDetail, fallbackCode, fallbackDetail string) string {
|
|
parts := make([]string, 0, 2)
|
|
if primaryCode != "" {
|
|
if primaryDetail != "" {
|
|
parts = append(parts, fmt.Sprintf("primary=%s (%s)", primaryCode, primaryDetail))
|
|
} else {
|
|
parts = append(parts, "primary="+primaryCode)
|
|
}
|
|
}
|
|
if fallbackCode != "" {
|
|
if fallbackDetail != "" {
|
|
parts = append(parts, fmt.Sprintf("fallback=%s (%s)", fallbackCode, fallbackDetail))
|
|
} else {
|
|
parts = append(parts, "fallback="+fallbackCode)
|
|
}
|
|
}
|
|
if len(parts) == 0 {
|
|
return "no suggestion diagnostics available"
|
|
}
|
|
return strings.Join(parts, "; ")
|
|
}
|
|
|
|
const (
|
|
legacyURLSuggestionUnavailablePrefix = "[URL suggestion unavailable:"
|
|
legacyURLSuggestionSourcePrefix = "[URL suggestion source:"
|
|
)
|
|
|
|
func parseLegacyURLSuggestionReasoning(reasoning string) (cleanedReasoning, sourceCode, sourceDetail, diagnostic string) {
|
|
cleaned := strings.TrimSpace(reasoning)
|
|
for {
|
|
changed := false
|
|
if note, next, ok := consumeLegacyURLSuggestionNote(cleaned, legacyURLSuggestionUnavailablePrefix); ok {
|
|
if diagnostic == "" {
|
|
diagnostic = note
|
|
}
|
|
cleaned = next
|
|
changed = true
|
|
}
|
|
if note, next, ok := consumeLegacyURLSuggestionNote(cleaned, legacyURLSuggestionSourcePrefix); ok {
|
|
if sourceCode == "" && sourceDetail == "" {
|
|
sourceCode, sourceDetail = parseLegacyURLSuggestionSource(note)
|
|
}
|
|
cleaned = next
|
|
changed = true
|
|
}
|
|
if !changed {
|
|
break
|
|
}
|
|
}
|
|
return cleaned, sourceCode, sourceDetail, diagnostic
|
|
}
|
|
|
|
func consumeLegacyURLSuggestionNote(text, prefix string) (note, remaining string, ok bool) {
|
|
trimmed := strings.TrimSpace(text)
|
|
if !strings.HasPrefix(trimmed, prefix) {
|
|
return "", text, false
|
|
}
|
|
|
|
closingBracket := strings.Index(trimmed, "]")
|
|
if closingBracket <= len(prefix) {
|
|
return "", text, false
|
|
}
|
|
|
|
note = strings.TrimSpace(trimmed[len(prefix):closingBracket])
|
|
remaining = strings.TrimSpace(trimmed[closingBracket+1:])
|
|
return note, remaining, true
|
|
}
|
|
|
|
func parseLegacyURLSuggestionSource(note string) (sourceCode, sourceDetail string) {
|
|
trimmed := strings.TrimSpace(note)
|
|
if trimmed == "" {
|
|
return "", ""
|
|
}
|
|
if strings.HasSuffix(trimmed, ")") {
|
|
if idx := strings.Index(trimmed, " ("); idx > 0 {
|
|
sourceCode = strings.TrimSpace(trimmed[:idx])
|
|
sourceDetail = strings.TrimSpace(trimmed[idx+2 : len(trimmed)-1])
|
|
if sourceCode != "" {
|
|
return sourceCode, sourceDetail
|
|
}
|
|
}
|
|
}
|
|
return trimmed, ""
|
|
}
|
|
|
|
func (s *Service) suggestHostManagementURLWithReason(req DiscoveryRequest, host string) (string, string, string) {
|
|
if req.ResourceType != ResourceTypeAgent {
|
|
return "", "host_fallback_not_applicable", "not a host resource"
|
|
}
|
|
if host == "" {
|
|
return "", "no_host", "no host or IP candidate available"
|
|
}
|
|
snap, ok := s.getSnapshot()
|
|
if !ok {
|
|
return "", "state_provider_unavailable", "state unavailable"
|
|
}
|
|
requestTargetID := canonicalRequestTargetID(req)
|
|
|
|
nodeMatchesReq := func(node Node) bool {
|
|
return node.ID == requestTargetID ||
|
|
node.Name == requestTargetID ||
|
|
node.ID == req.ResourceID ||
|
|
node.Name == req.ResourceID ||
|
|
(req.Hostname != "" && node.Name == req.Hostname)
|
|
}
|
|
|
|
var matchedHost *Host
|
|
for i := range snap.Hosts {
|
|
h := &snap.Hosts[i]
|
|
if h.ID == requestTargetID || h.Hostname == requestTargetID || h.ID == req.ResourceID || h.Hostname == req.ResourceID {
|
|
matchedHost = h
|
|
break
|
|
}
|
|
}
|
|
|
|
// Proxmox nodes (or host agents linked to nodes) should suggest the node UI.
|
|
for _, node := range snap.Nodes {
|
|
if nodeMatchesReq(node) {
|
|
return buildURL("https", host, 8006, ""), "host_management_profile_proxmox_node", "Proxmox node profile"
|
|
}
|
|
if matchedHost != nil && node.LinkedAgentID != "" && node.LinkedAgentID == matchedHost.ID {
|
|
return buildURL("https", host, 8006, ""), "host_management_profile_linked_proxmox_node", "Linked Proxmox node profile"
|
|
}
|
|
}
|
|
|
|
if matchedHost == nil {
|
|
return "", "host_not_found_in_state", "host not found in state"
|
|
}
|
|
|
|
descriptor := strings.ToLower(strings.TrimSpace(strings.Join([]string{
|
|
matchedHost.OSName,
|
|
matchedHost.DisplayName,
|
|
matchedHost.Platform,
|
|
}, " ")))
|
|
|
|
switch {
|
|
case strings.Contains(descriptor, "proxmox backup"):
|
|
return buildURL("https", host, 8007, ""), "host_management_profile_pbs", "Proxmox Backup profile"
|
|
case strings.Contains(descriptor, "proxmox mail gateway"), strings.Contains(descriptor, "pmg"):
|
|
return buildURL("https", host, 8006, ""), "host_management_profile_pmg", "Proxmox Mail Gateway profile"
|
|
case strings.Contains(descriptor, "proxmox ve"), strings.Contains(descriptor, "proxmox"):
|
|
return buildURL("https", host, 8006, ""), "host_management_profile_pve", "Proxmox VE profile"
|
|
case strings.Contains(descriptor, "truenas"),
|
|
strings.Contains(descriptor, "unraid"),
|
|
strings.Contains(descriptor, "openmediavault"):
|
|
return buildURL("http", host, 80, ""), "host_management_profile_nas", "NAS management profile"
|
|
default:
|
|
return "", "host_platform_not_recognized", "unknown host management profile"
|
|
}
|
|
}
|
|
|
|
// formatCLIAccess formats the CLI access string with actual values.
|
|
func (s *Service) formatCLIAccess(resourceType ResourceType, resourceID, cliTemplate string) string {
|
|
if cliTemplate == "" {
|
|
// Use default template
|
|
cliTemplate = GetCLIAccessTemplate(resourceType)
|
|
}
|
|
|
|
result := cliTemplate
|
|
result = strings.ReplaceAll(result, "{vmid}", resourceID)
|
|
result = strings.ReplaceAll(result, "{container}", resourceID)
|
|
result = strings.ReplaceAll(result, "{command}", "...")
|
|
|
|
return result
|
|
}
|
|
|
|
// buildMetadataAnalysisPrompt builds a prompt for shallow metadata-based analysis.
|
|
func (s *Service) buildMetadataAnalysisPrompt(c DockerContainer, host DockerHost) string {
|
|
info := map[string]any{
|
|
"name": c.Name,
|
|
"image": c.Image,
|
|
"status": c.Status,
|
|
"host": host.Hostname,
|
|
}
|
|
|
|
if len(c.Ports) > 0 {
|
|
var ports []map[string]any
|
|
for _, p := range c.Ports {
|
|
ports = append(ports, map[string]any{
|
|
"public": p.PublicPort,
|
|
"private": p.PrivatePort,
|
|
"protocol": p.Protocol,
|
|
})
|
|
}
|
|
info["ports"] = ports
|
|
}
|
|
|
|
if len(c.Labels) > 0 {
|
|
// Filter sensitive labels before sending to AI
|
|
info["labels"] = filterSensitiveLabels(c.Labels)
|
|
}
|
|
|
|
if len(c.Mounts) > 0 {
|
|
var mounts []string
|
|
for _, m := range c.Mounts {
|
|
mounts = append(mounts, m.Destination)
|
|
}
|
|
info["mounts"] = mounts
|
|
}
|
|
|
|
infoJSON, err := json.MarshalIndent(info, "", " ")
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("container", c.Name).Msg("Failed to marshal Docker metadata for discovery prompt")
|
|
infoJSON = []byte("{}")
|
|
}
|
|
|
|
return fmt.Sprintf(`Analyze this Docker container and identify what service it's running.
|
|
|
|
Container Information:
|
|
%s
|
|
|
|
Based on the image name, ports, labels, and mounts, determine:
|
|
1. What service/application is this?
|
|
2. What category does it belong to?
|
|
3. How should CLI commands be executed?
|
|
|
|
Respond in this exact JSON format:
|
|
{
|
|
"service_type": "lowercase_type",
|
|
"service_name": "Human Readable Name",
|
|
"service_version": "version if detectable from image tag",
|
|
"category": "database|web_server|cache|monitoring|backup|nvr|storage|container|network|security|media|home_automation|unknown",
|
|
"cli_access": "docker exec {container} <cli-tool>",
|
|
"facts": [],
|
|
"config_paths": [],
|
|
"data_paths": [],
|
|
"log_paths": [],
|
|
"ports": [],
|
|
"confidence": 0.0-1.0,
|
|
"reasoning": "Brief explanation"
|
|
}
|
|
|
|
Respond with ONLY valid JSON.`, string(infoJSON))
|
|
}
|
|
|
|
// buildDeepAnalysisPrompt builds a prompt for deep analysis with command outputs.
|
|
func (s *Service) buildDeepAnalysisPrompt(req AIAnalysisRequest) string {
|
|
var sections []string
|
|
|
|
sections = append(sections, fmt.Sprintf(`Resource Type: %s
|
|
Resource ID: %s
|
|
Target: %s (%s)`, req.ResourceType, req.ResourceID, req.Hostname, req.TargetID))
|
|
|
|
if len(req.Metadata) > 0 {
|
|
metaJSON, err := json.MarshalIndent(req.Metadata, "", " ")
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("Failed to marshal discovery metadata for analysis prompt")
|
|
sections = append(sections, fmt.Sprintf("Metadata:\n%v", req.Metadata))
|
|
} else {
|
|
sections = append(sections, fmt.Sprintf("Metadata:\n%s", string(metaJSON)))
|
|
}
|
|
}
|
|
|
|
if len(req.CommandOutputs) > 0 {
|
|
sections = append(sections, "Command Outputs:")
|
|
for name, output := range req.CommandOutputs {
|
|
// Truncate long outputs
|
|
if len(output) > 2000 {
|
|
output = output[:2000] + "\n... (truncated)"
|
|
}
|
|
sections = append(sections, fmt.Sprintf("--- %s ---\n%s", name, output))
|
|
}
|
|
}
|
|
|
|
// Use different prompts for HOST vs other resource types
|
|
if req.ResourceType == ResourceTypeAgent {
|
|
return fmt.Sprintf(`Analyze this HOST system and provide detailed discovery information.
|
|
|
|
%s
|
|
|
|
IMPORTANT: This is a HOST discovery. Focus on identifying the HOST OPERATING SYSTEM and its primary role/purpose, NOT individual services or containers running on it.
|
|
|
|
Based on all available information, determine:
|
|
1. What is the host operating system? (e.g., Unraid, Proxmox, Ubuntu Server, Debian, TrueNAS)
|
|
2. What is the OS version?
|
|
3. What is the primary role/purpose of this host? (e.g., NAS, hypervisor, media server, backup server)
|
|
4. What are the key system paths?
|
|
5. What storage is available?
|
|
6. What services are running? (list as facts, not as the primary identification)
|
|
|
|
Respond in this exact JSON format:
|
|
{
|
|
"service_type": "lowercase_os_type (e.g., unraid, proxmox, ubuntu, debian, truenas)",
|
|
"service_name": "Human Readable OS Name and Role (e.g., Unraid NAS Server, Proxmox VE Hypervisor)",
|
|
"service_version": "OS version number",
|
|
"category": "storage|virtualizer|container|network|unknown",
|
|
"cli_access": "ssh user@hostname",
|
|
"facts": [
|
|
{"category": "version|config|service|port|hardware|network|storage|dependency|security", "key": "fact_name", "value": "fact_value", "source": "command_name", "confidence": 0.9}
|
|
],
|
|
"config_paths": ["/etc/", "/boot/config/"],
|
|
"data_paths": ["/mnt/data", "/storage"],
|
|
"log_paths": ["/var/log/"],
|
|
"ports": [{"port": 22, "protocol": "tcp", "process": "sshd", "address": "0.0.0.0"}],
|
|
"confidence": 0.0-1.0,
|
|
"reasoning": "Explanation of host identification"
|
|
}
|
|
|
|
Important:
|
|
- The service_type and service_name MUST reflect the HOST OS, not services running on it
|
|
- List Docker containers, VMs, or other services as facts with category "service"
|
|
- Include storage information (disks, pools, arrays) as facts with category "storage"
|
|
- Include hardware info (CPU, RAM) as facts with category "hardware"
|
|
- Keep the response concise: include only the most important facts
|
|
- Limit output to at most 12 facts, 6 config_paths, 6 data_paths, 6 log_paths, and 12 ports
|
|
- Do not enumerate every package, service, mount, or file when a summarized representative set is enough
|
|
|
|
Respond with ONLY valid JSON.`, strings.Join(sections, "\n\n"))
|
|
}
|
|
|
|
return fmt.Sprintf(`Analyze this infrastructure resource and provide detailed discovery information.
|
|
|
|
%s
|
|
|
|
Based on all available information, determine:
|
|
1. What service/application is running?
|
|
2. What version is it?
|
|
3. What are the important configuration paths?
|
|
4. What data paths should be backed up?
|
|
5. What log paths are useful for troubleshooting?
|
|
6. What ports are in use?
|
|
7. Any special hardware (GPU, TPU, etc.)?
|
|
8. Any dependencies (databases, message queues, etc.)?
|
|
9. How is the service managed — what restarts or reloads it (systemd unit, or container restart)?
|
|
|
|
Respond in this exact JSON format:
|
|
{
|
|
"service_type": "lowercase_type (e.g., frigate, postgres, pbs)",
|
|
"service_name": "Human Readable Name",
|
|
"service_version": "version number if found",
|
|
"category": "database|web_server|cache|monitoring|backup|nvr|storage|container|virtualizer|network|security|media|home_automation|unknown",
|
|
"cli_access": "command to access this service's CLI",
|
|
"facts": [
|
|
{"category": "version|config|service|port|hardware|network|storage|dependency|security", "key": "fact_name", "value": "fact_value", "source": "command_name", "confidence": 0.9}
|
|
],
|
|
"config_paths": ["/path/to/config.yml"],
|
|
"data_paths": ["/path/to/data"],
|
|
"log_paths": ["/var/log/service/", "/path/to/app.log"],
|
|
"ports": [{"port": 8080, "protocol": "tcp", "process": "nginx", "address": "0.0.0.0"}],
|
|
"confidence": 0.0-1.0,
|
|
"reasoning": "Explanation of identification"
|
|
}
|
|
|
|
Important:
|
|
- Extract version numbers from package lists, process output, or config files
|
|
- Identify config and data paths from mount points and file listings
|
|
- Identify log paths (e.g., /var/log/, application-specific logs) for troubleshooting
|
|
- In config_paths, include the specific key files a user edits (e.g. configuration.yaml, automations.yaml, postgresql.conf), not just the parent config directory
|
|
- Capture how the service is managed as a fact with category "service" (e.g. the systemd unit "home-assistant.service", or a reload command) so it can be restarted or reloaded
|
|
- Note any special hardware like Coral TPU, NVIDIA GPU
|
|
- For LXC/VM, the CLI access should use pct exec or qm guest exec
|
|
- For Docker, use docker exec
|
|
- Keep the response concise: include only the most important facts
|
|
- Limit output to at most 12 facts, 6 config_paths, 6 data_paths, 6 log_paths, and 12 ports
|
|
- Do not enumerate every package, service, mount, or file when a summarized representative set is enough
|
|
|
|
Respond with ONLY valid JSON.`, strings.Join(sections, "\n\n"))
|
|
}
|
|
|
|
// parseAIResponse parses the AI's JSON response.
|
|
// resourceIdentityNeedsCommandEvidence reports whether a resource type's service
|
|
// identity must come from inside the workload (command output) rather than from
|
|
// its own metadata. Workloads (containers and VMs) qualify; host agents do not —
|
|
// they are identified from their own rich metadata, so metadata-only analysis is
|
|
// legitimate for them. Used to decide when to abstain rather than let the model
|
|
// fabricate an identity with no command evidence.
|
|
func resourceIdentityNeedsCommandEvidence(rt ResourceType) bool {
|
|
switch rt {
|
|
case ResourceTypeSystemContainer,
|
|
ResourceTypeVM,
|
|
ResourceTypeDocker,
|
|
ResourceTypeDockerSystemContainer,
|
|
ResourceTypeDockerVM,
|
|
ResourceTypeK8s:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// metadataOnlyDiscoveryAbstention returns an empty, zero-confidence analysis
|
|
// used when discovery has no command-execution evidence. It deliberately carries
|
|
// no service identity, facts, paths, or ports so the UI shows "not determined"
|
|
// instead of a fabricated guess. CLIAccess is left empty so the platform-correct
|
|
// access command is generated downstream rather than invented by the model.
|
|
func metadataOnlyDiscoveryAbstention() *AIAnalysisResponse {
|
|
// This abstention is only reached when command scanning is already enabled
|
|
// (see commandScanAttempted in DiscoverResource) but the host agent returned
|
|
// no command output — so the honest cause is the agent not being reachable
|
|
// for execution, NOT a disabled "Pulse Commands" toggle. Do not tell the user
|
|
// to enable something that is already on; point them at agent connectivity and
|
|
// the agent:exec token scope instead.
|
|
return &AIAnalysisResponse{
|
|
Confidence: 0,
|
|
Reasoning: "Discovery couldn't collect command output from inside this resource, so its service wasn't identified. Pulse Commands are enabled, but the host agent returned no results — make sure the host agent is connected and its API token has the \"agent:exec\" scope.",
|
|
}
|
|
}
|
|
|
|
func (s *Service) parseAIResponse(response string) *AIAnalysisResponse {
|
|
log.Debug().Str("raw_response", response).Msg("discovery raw response")
|
|
payload, ok := jsonresponse.ExtractObject(response)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
var result AIAnalysisResponse
|
|
if err := json.Unmarshal([]byte(payload), &result); err != nil {
|
|
log.Debug().Err(err).Str("response", payload).Msg("failed to parse AI response")
|
|
return nil
|
|
}
|
|
|
|
// Set discovered_at for facts
|
|
now := time.Now()
|
|
for i := range result.Facts {
|
|
result.Facts[i].DiscoveredAt = now
|
|
}
|
|
|
|
return &result
|
|
}
|
|
|
|
// parseDockerMounts parses the docker_mounts command output into a slice of DockerBindMount.
|
|
// The output format is:
|
|
// CONTAINER:container_name
|
|
// source|destination|type
|
|
// source|destination|type
|
|
// CONTAINER:another_container
|
|
// source|destination|type
|
|
func parseDockerMounts(output string) []DockerBindMount {
|
|
if output == "" || output == "no_docker_mounts" {
|
|
return nil
|
|
}
|
|
|
|
var mounts []DockerBindMount
|
|
var currentContainer string
|
|
|
|
lines := strings.Split(output, "\n")
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
// Check if this is a container header
|
|
if strings.HasPrefix(line, "CONTAINER:") {
|
|
currentContainer = strings.TrimPrefix(line, "CONTAINER:")
|
|
continue
|
|
}
|
|
|
|
// Skip if we don't have a current container
|
|
if currentContainer == "" {
|
|
continue
|
|
}
|
|
|
|
// Parse mount line: source|destination|type
|
|
parts := strings.Split(line, "|")
|
|
if len(parts) < 2 {
|
|
continue
|
|
}
|
|
|
|
mount := DockerBindMount{
|
|
ContainerName: currentContainer,
|
|
Source: parts[0],
|
|
Destination: parts[1],
|
|
}
|
|
if len(parts) >= 3 {
|
|
mount.Type = parts[2]
|
|
}
|
|
|
|
// Only include bind mounts and volumes (skip tmpfs, etc.)
|
|
if mount.Type == "" || mount.Type == "bind" || mount.Type == "volume" {
|
|
mounts = append(mounts, mount)
|
|
}
|
|
}
|
|
|
|
return mounts
|
|
}
|
|
|
|
// GetDiscovery retrieves a discovery by ID.
|
|
func (s *Service) GetDiscovery(id string) (*ResourceDiscovery, error) {
|
|
d, err := s.store.Get(id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get discovery %q: %w", id, err)
|
|
}
|
|
if d == nil {
|
|
return nil, nil
|
|
}
|
|
s.upgradeCLIAccessIfNeeded(d)
|
|
return d, nil
|
|
}
|
|
|
|
func (s *Service) GetDiscoveryByResource(resourceType ResourceType, targetID, resourceID string) (*ResourceDiscovery, error) {
|
|
resourceType = NormalizeResourceType(resourceType)
|
|
if err := ValidateCanonicalDiscoveryResourceType(resourceType); err != nil {
|
|
return nil, fmt.Errorf("get discovery for %s/%s/%s: %w", resourceType, targetID, resourceID, err)
|
|
}
|
|
|
|
req := DiscoveryRequest{
|
|
ResourceType: resourceType,
|
|
TargetID: targetID,
|
|
ResourceID: resourceID,
|
|
}
|
|
aliasIDs := []string{MakeResourceID(resourceType, targetID, resourceID)}
|
|
req = s.normalizeDiscoveryRequest(req, &aliasIDs)
|
|
|
|
d, err := s.store.GetByResource(resourceType, req.TargetID, req.ResourceID)
|
|
if err != nil || d == nil {
|
|
for _, aliasID := range aliasIDs {
|
|
if aliasID == "" {
|
|
continue
|
|
}
|
|
dAlias, errAlias := s.store.Get(aliasID)
|
|
if errAlias == nil && dAlias != nil {
|
|
s.upgradeCLIAccessIfNeeded(dAlias)
|
|
return dAlias, nil
|
|
}
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get discovery for %s/%s/%s: %w", resourceType, req.TargetID, req.ResourceID, err)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
s.upgradeCLIAccessIfNeeded(d)
|
|
return d, nil
|
|
}
|
|
|
|
// ListDiscoveries returns all discoveries.
|
|
func (s *Service) ListDiscoveries() ([]*ResourceDiscovery, error) {
|
|
discoveries, err := s.store.List()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list discoveries: %w", err)
|
|
}
|
|
discoveries = s.deduplicateDiscoveries(discoveries)
|
|
for _, d := range discoveries {
|
|
s.upgradeCLIAccessIfNeeded(d)
|
|
}
|
|
return discoveries, nil
|
|
}
|
|
|
|
// ListDiscoveriesByType returns discoveries for a specific resource type.
|
|
func (s *Service) ListDiscoveriesByType(resourceType ResourceType) ([]*ResourceDiscovery, error) {
|
|
resourceType = NormalizeResourceType(resourceType)
|
|
if err := ValidateCanonicalDiscoveryResourceType(resourceType); err != nil {
|
|
return nil, fmt.Errorf("list discoveries by type %q: %w", resourceType, err)
|
|
}
|
|
|
|
discoveries, err := s.store.ListByType(resourceType)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list discoveries by type %q: %w", resourceType, err)
|
|
}
|
|
discoveries = s.deduplicateDiscoveries(discoveries)
|
|
for _, d := range discoveries {
|
|
s.upgradeCLIAccessIfNeeded(d)
|
|
}
|
|
return discoveries, nil
|
|
}
|
|
|
|
// ListDiscoveriesByTarget returns discoveries for a specific target ID.
|
|
func (s *Service) ListDiscoveriesByTarget(targetID string) ([]*ResourceDiscovery, error) {
|
|
discoveries, err := s.store.ListByTarget(targetID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list discoveries by target %q: %w", targetID, err)
|
|
}
|
|
discoveries = s.deduplicateDiscoveries(discoveries)
|
|
for _, d := range discoveries {
|
|
s.upgradeCLIAccessIfNeeded(d)
|
|
}
|
|
return discoveries, nil
|
|
}
|
|
|
|
// deduplicateDiscoveries filters out redundant discoveries where a PVE node
|
|
// is represented by both its Node Name and its Linked Host Agent ID.
|
|
// The Host Agent ID is preferred.
|
|
func (s *Service) deduplicateDiscoveries(discoveries []*ResourceDiscovery) []*ResourceDiscovery {
|
|
snap, ok := s.getSnapshot()
|
|
if !ok {
|
|
return discoveries
|
|
}
|
|
if len(snap.Nodes) == 0 {
|
|
return discoveries
|
|
}
|
|
|
|
// Map linked agent IDs to their PVE node source(s)
|
|
// AgentID -> NodeName
|
|
linkedAgents := make(map[string]string)
|
|
for _, node := range snap.Nodes {
|
|
if node.LinkedAgentID != "" {
|
|
linkedAgents[node.LinkedAgentID] = node.Name
|
|
}
|
|
}
|
|
|
|
if len(linkedAgents) == 0 {
|
|
return discoveries
|
|
}
|
|
|
|
// Check which agents actually have discovery data
|
|
hasAgentDiscovery := make(map[string]bool)
|
|
for _, d := range discoveries {
|
|
if d.ResourceType == ResourceTypeAgent {
|
|
discoveryTargetID := canonicalDiscoveryTargetID(d)
|
|
// discoveryTargetID is usually the agent ID for host resources
|
|
if _, ok := linkedAgents[discoveryTargetID]; ok {
|
|
hasAgentDiscovery[discoveryTargetID] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Filter out PVE node discoveries if the corresponding agent discovery exists
|
|
filtered := make([]*ResourceDiscovery, 0, len(discoveries))
|
|
for _, d := range discoveries {
|
|
if d.ResourceType == ResourceTypeAgent {
|
|
// If this discovery is for a PVE node (by name/ID)
|
|
// check if it maps to an agent that ALREADY has a discovery in this list
|
|
|
|
// Is this discovery's ID satisfying a Node check?
|
|
isPVENode := false
|
|
var linkedAgentID string
|
|
discoveryTargetID := canonicalDiscoveryTargetID(d)
|
|
|
|
for _, node := range snap.Nodes {
|
|
if discoveryTargetID == node.Name || discoveryTargetID == node.ID || d.ResourceID == node.Name {
|
|
isPVENode = true
|
|
linkedAgentID = node.LinkedAgentID
|
|
break
|
|
}
|
|
}
|
|
|
|
if isPVENode && linkedAgentID != "" && hasAgentDiscovery[linkedAgentID] && discoveryTargetID != linkedAgentID {
|
|
// We have the agent discovery, so skip this redundant PVE node discovery
|
|
continue
|
|
}
|
|
}
|
|
filtered = append(filtered, d)
|
|
}
|
|
|
|
return filtered
|
|
}
|
|
|
|
// upgradeCLIAccessIfNeeded upgrades cached discovery fields to current versions.
|
|
// This ensures cached discoveries get the new instructional CLI access format
|
|
// and have hostname populated without requiring a full re-discovery.
|
|
func (s *Service) upgradeCLIAccessIfNeeded(d *ResourceDiscovery) {
|
|
if d == nil {
|
|
return
|
|
}
|
|
|
|
upgraded := false
|
|
|
|
// Upgrade CLI access if version is outdated
|
|
if d.CLIAccessVersion < CLIAccessVersion {
|
|
oldCLI := d.CLIAccess
|
|
d.CLIAccess = GetCLIAccessTemplate(d.ResourceType)
|
|
d.CLIAccessVersion = CLIAccessVersion
|
|
upgraded = true
|
|
|
|
log.Debug().
|
|
Str("id", d.ID).
|
|
Str("old_cli", oldCLI).
|
|
Str("new_cli", d.CLIAccess).
|
|
Int("new_version", CLIAccessVersion).
|
|
Msg("Upgraded CLI access pattern to new version")
|
|
}
|
|
|
|
// Fix empty hostname by looking up the resource name from state
|
|
if d.Hostname == "" {
|
|
if snap, ok := s.getSnapshot(); ok {
|
|
hostname := s.lookupHostnameFromState(d.ResourceType, canonicalDiscoveryTargetID(d), d.ResourceID, snap)
|
|
if hostname != "" {
|
|
d.Hostname = hostname
|
|
upgraded = true
|
|
log.Debug().
|
|
Str("id", d.ID).
|
|
Str("hostname", hostname).
|
|
Msg("Populated missing hostname from state")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Migrate legacy URL suggestion notes from AI reasoning into structured fields.
|
|
cleanedReasoning, sourceCode, sourceDetail, diagnostic := parseLegacyURLSuggestionReasoning(d.AIReasoning)
|
|
if d.SuggestedURLSourceCode == "" && sourceCode != "" {
|
|
d.SuggestedURLSourceCode = sourceCode
|
|
upgraded = true
|
|
}
|
|
if d.SuggestedURLSourceDetail == "" && sourceDetail != "" {
|
|
d.SuggestedURLSourceDetail = sourceDetail
|
|
upgraded = true
|
|
}
|
|
if d.SuggestedURLDiagnostic == "" && diagnostic != "" {
|
|
d.SuggestedURLDiagnostic = diagnostic
|
|
upgraded = true
|
|
}
|
|
if cleanedReasoning != d.AIReasoning {
|
|
d.AIReasoning = cleanedReasoning
|
|
upgraded = true
|
|
}
|
|
|
|
req := discoveryRequestForStoredDiscovery(d)
|
|
var metadata map[string]any
|
|
if s.hasStateAccess() {
|
|
metadata = s.getResourceMetadata(req)
|
|
if req.Hostname == "" {
|
|
req.Hostname = stringMetadataValue(metadata, "name", "hostname", "display_name")
|
|
}
|
|
}
|
|
if changed, _ := applyKnownServiceIdentity(d, req, metadata, d.RawCommandOutput); changed {
|
|
upgraded = true
|
|
}
|
|
if s.refreshSuggestedURLFromState(d, req) {
|
|
upgraded = true
|
|
}
|
|
if s.refreshSuggestedAvailabilityProbeFromState(d, req) {
|
|
upgraded = true
|
|
}
|
|
|
|
_ = upgraded // Suppress unused variable warning if logging is disabled
|
|
}
|
|
|
|
func (s *Service) refreshSuggestedURLFromState(d *ResourceDiscovery, req DiscoveryRequest) bool {
|
|
if d == nil || d.SuggestedURL != "" || !s.hasStateAccess() {
|
|
return false
|
|
}
|
|
|
|
externalIP := s.getResourceExternalIP(req)
|
|
if externalIP == "" {
|
|
return false
|
|
}
|
|
|
|
url, code, detail := suggestWebURLWithReason(d, externalIP)
|
|
if url == "" {
|
|
url, code, detail = s.suggestHostManagementURLWithReason(req, externalIP)
|
|
}
|
|
if url == "" {
|
|
return false
|
|
}
|
|
|
|
changed := false
|
|
if d.SuggestedURL != url {
|
|
d.SuggestedURL = url
|
|
changed = true
|
|
}
|
|
if d.SuggestedURLSourceCode != code {
|
|
d.SuggestedURLSourceCode = code
|
|
changed = true
|
|
}
|
|
if d.SuggestedURLSourceDetail != detail {
|
|
d.SuggestedURLSourceDetail = detail
|
|
changed = true
|
|
}
|
|
if d.SuggestedURLDiagnostic != "" {
|
|
d.SuggestedURLDiagnostic = ""
|
|
changed = true
|
|
}
|
|
return changed
|
|
}
|
|
|
|
func (s *Service) refreshSuggestedAvailabilityProbeFromState(d *ResourceDiscovery, req DiscoveryRequest) bool {
|
|
if d == nil || d.SuggestedAvailabilityProbe != nil || !s.hasStateAccess() {
|
|
return false
|
|
}
|
|
externalIP := s.getResourceExternalIP(req)
|
|
if externalIP == "" {
|
|
return false
|
|
}
|
|
suggestion := SuggestAvailabilityProbe(d, externalIP)
|
|
if suggestion == nil {
|
|
return false
|
|
}
|
|
d.SuggestedAvailabilityProbe = suggestion
|
|
return true
|
|
}
|
|
|
|
func (s *Service) backfillAvailabilitySuggestions(ctx context.Context) {
|
|
if ctx == nil || ctx.Err() != nil {
|
|
return
|
|
}
|
|
if !s.hasStateAccess() || s.store == nil {
|
|
return
|
|
}
|
|
|
|
discoveries, err := s.store.List()
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("Failed to list discoveries for availability suggestion backfill")
|
|
return
|
|
}
|
|
if len(discoveries) == 0 {
|
|
return
|
|
}
|
|
|
|
const maxEmptyRetries = 6
|
|
initialDelay := 10 * time.Second
|
|
|
|
for attempt := 0; attempt < maxEmptyRetries; attempt++ {
|
|
snap, ok := s.getSnapshot()
|
|
if !ok {
|
|
return
|
|
}
|
|
hasInfra := len(snap.Containers) > 0 || len(snap.VMs) > 0 || len(snap.DockerHosts) > 0
|
|
if hasInfra || attempt == maxEmptyRetries-1 {
|
|
if !hasInfra {
|
|
log.Warn().Msg("backfill: state snapshot still empty after retries; proceeding with best effort")
|
|
}
|
|
break
|
|
}
|
|
log.Info().Dur("delay", initialDelay).Int("attempt", attempt+1).Msg("backfill: state snapshot empty, waiting for monitor data")
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(initialDelay):
|
|
}
|
|
initialDelay *= 2
|
|
}
|
|
|
|
log.Info().Int("discoveries", len(discoveries)).Msg("backfill: processing discoveries")
|
|
updated := 0
|
|
for _, d := range discoveries {
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
if d.SuggestedAvailabilityProbe != nil {
|
|
continue
|
|
}
|
|
|
|
resourceType, targetID, resourceID, err := ParseResourceID(d.ID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
req := DiscoveryRequest{
|
|
ResourceType: resourceType,
|
|
ResourceID: resourceID,
|
|
TargetID: targetID,
|
|
}
|
|
|
|
externalIP := s.getResourceExternalIP(req)
|
|
suggestion := SuggestAvailabilityProbe(d, externalIP)
|
|
if suggestion != nil {
|
|
d.SuggestedAvailabilityProbe = suggestion
|
|
if err := s.store.Save(d); err != nil {
|
|
log.Warn().Err(err).Str("id", d.ID).Msg("Failed to save backfilled availability suggestion")
|
|
} else {
|
|
updated++
|
|
}
|
|
}
|
|
}
|
|
|
|
if updated > 0 {
|
|
log.Info().Int("count", updated).Msg("Backfilled availability probe suggestions")
|
|
}
|
|
}
|
|
|
|
// lookupHostnameFromState finds the hostname/name for a resource from state
|
|
func (s *Service) lookupHostnameFromState(resourceType ResourceType, hostID, resourceID string, snap StateSnapshot) string {
|
|
switch resourceType {
|
|
case ResourceTypeSystemContainer:
|
|
for _, c := range snap.Containers {
|
|
if fmt.Sprintf("%d", c.VMID) == resourceID && c.Node == hostID {
|
|
return c.Name
|
|
}
|
|
}
|
|
case ResourceTypeVM:
|
|
for _, vm := range snap.VMs {
|
|
if fmt.Sprintf("%d", vm.VMID) == resourceID && vm.Node == hostID {
|
|
return vm.Name
|
|
}
|
|
}
|
|
case ResourceTypeDocker:
|
|
for _, host := range snap.DockerHosts {
|
|
if host.AgentID == hostID || host.Hostname == hostID {
|
|
for _, c := range host.Containers {
|
|
if c.Name == resourceID {
|
|
return host.Hostname
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// UpdateNotes updates user notes for a discovery.
|
|
func (s *Service) UpdateNotes(id string, notes string, secrets map[string]string) error {
|
|
return s.store.UpdateNotes(id, notes, secrets)
|
|
}
|
|
|
|
// DeleteDiscovery deletes a discovery.
|
|
func (s *Service) DeleteDiscovery(id string) error {
|
|
return s.store.Delete(id)
|
|
}
|
|
|
|
// GetProgress returns the progress of an ongoing discovery.
|
|
func (s *Service) GetProgress(resourceID string) *DiscoveryProgress {
|
|
if s.scanner == nil {
|
|
return nil
|
|
}
|
|
return s.scanner.GetProgress(resourceID)
|
|
}
|
|
|
|
// GetStatus returns the service status including fingerprint statistics.
|
|
func (s *Service) GetStatus() map[string]any {
|
|
return s.GetStatusSnapshot().ToMap()
|
|
}
|
|
|
|
// GetStatusSnapshot returns the typed status snapshot including fingerprint statistics.
|
|
func (s *Service) GetStatusSnapshot() ServiceStatus {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
s.cacheMu.RLock()
|
|
cacheSize := len(s.analysisCache)
|
|
s.cacheMu.RUnlock()
|
|
|
|
// Get fingerprint stats
|
|
fingerprintCount := 0
|
|
var lastFingerprintScan time.Time
|
|
if s.store != nil {
|
|
fingerprintCount = s.store.GetFingerprintCount()
|
|
lastFingerprintScan = s.store.GetLastFingerprintScan()
|
|
}
|
|
|
|
return ServiceStatus{
|
|
Running: s.running,
|
|
LastRun: s.lastRun,
|
|
Interval: s.interval.String(),
|
|
CacheSize: cacheSize,
|
|
AIAnalyzerSet: s.aiAnalyzer != nil,
|
|
ScannerSet: s.scanner != nil,
|
|
StoreSet: s.store != nil,
|
|
CommandScanning: s.commandScanning,
|
|
DeepScanTimeout: s.deepScanTimeout.String(),
|
|
AIAnalysisTimeout: s.aiAnalysisTimeout.String(),
|
|
MaxDiscoveryAge: s.maxDiscoveryAge.String(),
|
|
FingerprintCount: fingerprintCount,
|
|
LastFingerprintScan: lastFingerprintScan,
|
|
}
|
|
}
|
|
|
|
func canonicalDiscoveryTargetID(discovery *ResourceDiscovery) string {
|
|
if discovery == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(discovery.TargetID)
|
|
}
|
|
|
|
func canonicalRequestTargetID(req DiscoveryRequest) string {
|
|
return strings.TrimSpace(req.TargetID)
|
|
}
|
|
|
|
func normalizeDiscoveryRequestAliases(req DiscoveryRequest) DiscoveryRequest {
|
|
req.ResourceType = NormalizeResourceType(req.ResourceType)
|
|
req.TargetID = canonicalRequestTargetID(req)
|
|
req.ResourceID = strings.TrimSpace(req.ResourceID)
|
|
req.Hostname = strings.TrimSpace(req.Hostname)
|
|
return req
|
|
}
|
|
|
|
// GetMaxDiscoveryAge returns the current max discovery age (staleness threshold).
|
|
func (s *Service) GetMaxDiscoveryAge() time.Duration {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.maxDiscoveryAge
|
|
}
|
|
|
|
// SetMaxDiscoveryAge updates the max discovery age (staleness threshold).
|
|
// Discoveries older than this duration will be re-run when requested.
|
|
func (s *Service) SetMaxDiscoveryAge(age time.Duration) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// Enforce minimum of 1 day
|
|
if age < minDiscoveryMaxAge {
|
|
age = minDiscoveryMaxAge
|
|
}
|
|
|
|
s.maxDiscoveryAge = age
|
|
log.Info().Dur("max_discovery_age", age).Msg("max discovery age updated")
|
|
}
|
|
|
|
// ClearCache clears the AI analysis cache.
|
|
func (s *Service) ClearCache() {
|
|
s.cacheMu.Lock()
|
|
defer s.cacheMu.Unlock()
|
|
s.analysisCache = make(map[string]*analysisCacheEntry)
|
|
}
|
|
|
|
// --- AI Chat Integration Methods ---
|
|
|
|
// GetDiscoveryForAIChat returns discovery data for AI chat context.
|
|
// It will run discovery if needed (fingerprint changed or no data exists).
|
|
// This is the just-in-time discovery approach: only call AI when data is actually needed.
|
|
func (s *Service) GetDiscoveryForAIChat(ctx context.Context, resourceType ResourceType, targetID, resourceID string) (*ResourceDiscovery, error) {
|
|
// This is the same as DiscoverResource but without Force
|
|
return s.DiscoverResource(ctx, DiscoveryRequest{
|
|
ResourceType: resourceType,
|
|
ResourceID: resourceID,
|
|
TargetID: targetID,
|
|
Force: false, // Let fingerprint logic decide
|
|
})
|
|
}
|
|
|
|
// GetDiscoveriesForAIContext returns discoveries for multiple resources.
|
|
// Used when AI chat needs context about the infrastructure.
|
|
// Only runs discovery for resources that actually need it (fingerprint changed).
|
|
func (s *Service) GetDiscoveriesForAIContext(ctx context.Context, resourceIDs []string) ([]*ResourceDiscovery, error) {
|
|
var results []*ResourceDiscovery
|
|
for _, id := range resourceIDs {
|
|
resourceType, targetID, resourceID, err := ParseResourceID(id)
|
|
if err != nil {
|
|
log.Debug().Err(err).Str("id", id).Msg("failed to parse resource ID for AI context")
|
|
continue
|
|
}
|
|
discovery, err := s.GetDiscoveryForAIChat(ctx, resourceType, targetID, resourceID)
|
|
if err != nil {
|
|
log.Debug().Err(err).Str("id", id).Msg("failed to get discovery for AI context")
|
|
continue
|
|
}
|
|
if discovery != nil {
|
|
results = append(results, discovery)
|
|
}
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// GetChangedResourceCount returns the count of resources whose fingerprint has changed
|
|
// since their last discovery.
|
|
func (s *Service) GetChangedResourceCount() (int, error) {
|
|
if s.store == nil {
|
|
return 0, nil
|
|
}
|
|
changed, err := s.store.GetChangedResources()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("get changed discovery resources: %w", err)
|
|
}
|
|
return len(changed), nil
|
|
}
|
|
|
|
// GetStaleResourceCount returns the count of resources whose discovery is older
|
|
// than maxDiscoveryAge.
|
|
func (s *Service) GetStaleResourceCount() (int, error) {
|
|
if s.store == nil {
|
|
return 0, nil
|
|
}
|
|
stale, err := s.store.GetStaleResources(s.maxDiscoveryAge)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("get stale discovery resources: %w", err)
|
|
}
|
|
return len(stale), nil
|
|
}
|