diff --git a/internal/aidiscovery/commands.go b/internal/aidiscovery/commands.go deleted file mode 100644 index 24da8ce90..000000000 --- a/internal/aidiscovery/commands.go +++ /dev/null @@ -1,442 +0,0 @@ -package aidiscovery - -import ( - "fmt" - "strings" -) - -// DiscoveryCommand represents a command to run during discovery. -type DiscoveryCommand struct { - Name string // Human-readable name - Command string // The command template - Description string // What this discovers - Categories []string // What categories of info this provides - Timeout int // Timeout in seconds (0 = default) - Optional bool // If true, don't fail if command fails -} - -// CommandSet represents a set of commands for a resource type. -type CommandSet struct { - ResourceType ResourceType - Commands []DiscoveryCommand -} - -// GetCommandsForResource returns the commands to run for a given resource type. -func GetCommandsForResource(resourceType ResourceType) []DiscoveryCommand { - switch resourceType { - case ResourceTypeLXC: - return getLXCCommands() - case ResourceTypeVM: - return getVMCommands() - case ResourceTypeDocker: - return getDockerCommands() - case ResourceTypeDockerVM, ResourceTypeDockerLXC: - return getNestedDockerCommands() - case ResourceTypeK8s: - return getK8sCommands() - case ResourceTypeHost: - return getHostCommands() - default: - return []DiscoveryCommand{} - } -} - -// getLXCCommands returns commands for discovering LXC containers. -func getLXCCommands() []DiscoveryCommand { - return []DiscoveryCommand{ - { - Name: "os_release", - Command: "cat /etc/os-release", - Description: "Operating system identification", - Categories: []string{"version", "config"}, - Optional: true, - }, - { - Name: "hostname", - Command: "hostname", - Description: "Container hostname", - Categories: []string{"config"}, - Optional: true, - }, - { - Name: "running_services", - Command: "systemctl list-units --type=service --state=running --no-pager 2>/dev/null | head -30 || service --status-all 2>/dev/null | grep '+' | head -30", - Description: "Running services and daemons", - Categories: []string{"service"}, - Optional: true, - }, - { - Name: "listening_ports", - Command: "ss -tlnp 2>/dev/null | head -25 || netstat -tlnp 2>/dev/null | head -25", - Description: "Network ports listening", - Categories: []string{"port", "network"}, - Optional: true, - }, - { - Name: "top_processes", - Command: "ps aux --sort=-rss 2>/dev/null | head -15 || ps aux | head -15", - Description: "Top processes by memory", - Categories: []string{"service"}, - Optional: true, - }, - { - Name: "disk_usage", - Command: "df -h 2>/dev/null | head -15", - Description: "Disk usage and mount points", - Categories: []string{"storage"}, - Optional: true, - }, - { - Name: "docker_check", - Command: "docker ps --format '{{.Names}}: {{.Image}} ({{.Status}})' 2>/dev/null | head -20 || echo 'no_docker'", - Description: "Docker containers if running", - Categories: []string{"service", "container"}, - Optional: true, - }, - { - Name: "installed_packages", - Command: "dpkg -l 2>/dev/null | grep -E '^ii' | awk '{print $2}' | head -50 || rpm -qa 2>/dev/null | head -50 || apk list --installed 2>/dev/null | head -50", - Description: "Installed packages", - Categories: []string{"version", "service"}, - Optional: true, - }, - { - Name: "config_files", - Command: "find /etc -name '*.conf' -o -name '*.yml' -o -name '*.yaml' -o -name '*.json' 2>/dev/null | head -30", - Description: "Configuration files", - Categories: []string{"config"}, - Optional: true, - }, - { - Name: "cron_jobs", - Command: "crontab -l 2>/dev/null | grep -v '^#' | head -10 || ls -la /etc/cron.d/ 2>/dev/null | head -10", - Description: "Scheduled jobs", - Categories: []string{"service"}, - Optional: true, - }, - { - Name: "hardware_info", - Command: "lspci 2>/dev/null | head -20 || echo 'no_lspci'", - Description: "Hardware devices (e.g., Coral TPU)", - Categories: []string{"hardware"}, - Optional: true, - }, - { - Name: "gpu_devices", - Command: "ls -la /dev/dri/ 2>/dev/null; ls -la /dev/apex* 2>/dev/null; nvidia-smi -L 2>/dev/null || echo 'no_gpu'", - Description: "GPU and TPU devices", - Categories: []string{"hardware"}, - Optional: true, - }, - } -} - -// getVMCommands returns commands for discovering VMs (via QEMU guest agent). -func getVMCommands() []DiscoveryCommand { - return []DiscoveryCommand{ - { - Name: "os_release", - Command: "cat /etc/os-release", - Description: "Operating system identification", - Categories: []string{"version", "config"}, - Optional: true, - }, - { - Name: "hostname", - Command: "hostname", - Description: "VM hostname", - Categories: []string{"config"}, - Optional: true, - }, - { - Name: "running_services", - Command: "systemctl list-units --type=service --state=running --no-pager 2>/dev/null | head -30", - Description: "Running services and daemons", - Categories: []string{"service"}, - Optional: true, - }, - { - Name: "listening_ports", - Command: "ss -tlnp 2>/dev/null | head -25 || netstat -tlnp 2>/dev/null | head -25", - Description: "Network ports listening", - Categories: []string{"port", "network"}, - Optional: true, - }, - { - Name: "top_processes", - Command: "ps aux --sort=-rss 2>/dev/null | head -15", - Description: "Top processes by memory", - Categories: []string{"service"}, - Optional: true, - }, - { - Name: "disk_usage", - Command: "df -h 2>/dev/null | head -15", - Description: "Disk usage and mount points", - Categories: []string{"storage"}, - Optional: true, - }, - { - Name: "docker_check", - Command: "docker ps --format '{{.Names}}: {{.Image}} ({{.Status}})' 2>/dev/null | head -20 || echo 'no_docker'", - Description: "Docker containers if running", - Categories: []string{"service", "container"}, - Optional: true, - }, - { - Name: "hardware_info", - Command: "lspci 2>/dev/null | head -20", - Description: "PCI hardware devices", - Categories: []string{"hardware"}, - Optional: true, - }, - { - Name: "gpu_devices", - Command: "ls -la /dev/dri/ 2>/dev/null; nvidia-smi -L 2>/dev/null || echo 'no_gpu'", - Description: "GPU devices", - Categories: []string{"hardware"}, - Optional: true, - }, - } -} - -// getDockerCommands returns commands for discovering Docker containers. -// These are run inside the container via docker exec. -func getDockerCommands() []DiscoveryCommand { - return []DiscoveryCommand{ - { - Name: "os_release", - Command: "cat /etc/os-release 2>/dev/null || cat /etc/alpine-release 2>/dev/null || echo 'unknown'", - Description: "Container OS", - Categories: []string{"version"}, - Optional: true, - }, - { - Name: "processes", - Command: "ps aux 2>/dev/null || echo 'no_ps'", - Description: "Running processes", - Categories: []string{"service"}, - Optional: true, - }, - { - Name: "listening_ports", - Command: "ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null || echo 'no_ss'", - Description: "Listening ports inside container", - Categories: []string{"port"}, - Optional: true, - }, - { - Name: "env_vars", - Command: "env 2>/dev/null | grep -vE '(PASSWORD|SECRET|KEY|TOKEN|CREDENTIAL)' | head -30", - Description: "Environment variables (filtered)", - Categories: []string{"config"}, - Optional: true, - }, - { - Name: "config_files", - Command: "find /config /data /app /etc -maxdepth 2 -name '*.conf' -o -name '*.yml' -o -name '*.yaml' -o -name '*.json' 2>/dev/null | head -20", - Description: "Configuration files", - Categories: []string{"config"}, - Optional: true, - }, - } -} - -// getNestedDockerCommands returns commands for Docker inside VMs or LXCs. -func getNestedDockerCommands() []DiscoveryCommand { - return []DiscoveryCommand{ - { - Name: "docker_containers", - Command: "docker ps -a --format '{{.Names}}|{{.Image}}|{{.Status}}|{{.Ports}}'", - Description: "All Docker containers", - Categories: []string{"container", "service"}, - Optional: false, - }, - { - Name: "docker_images", - Command: "docker images --format '{{.Repository}}:{{.Tag}}' | head -20", - Description: "Docker images", - Categories: []string{"version"}, - Optional: true, - }, - { - Name: "docker_compose", - Command: "find /opt /home /root -name 'docker-compose*.yml' -o -name 'compose*.yml' 2>/dev/null | head -10", - Description: "Docker compose files", - Categories: []string{"config"}, - Optional: true, - }, - } -} - -// getK8sCommands returns commands for discovering Kubernetes pods. -func getK8sCommands() []DiscoveryCommand { - return []DiscoveryCommand{ - { - Name: "processes", - Command: "ps aux 2>/dev/null || echo 'no_ps'", - Description: "Running processes in pod", - Categories: []string{"service"}, - Optional: true, - }, - { - Name: "listening_ports", - Command: "ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null || echo 'no_ss'", - Description: "Listening ports", - Categories: []string{"port"}, - Optional: true, - }, - { - Name: "env_vars", - Command: "env 2>/dev/null | grep -vE '(PASSWORD|SECRET|KEY|TOKEN|CREDENTIAL)' | head -30", - Description: "Environment variables (filtered)", - Categories: []string{"config"}, - Optional: true, - }, - } -} - -// getHostCommands returns commands for discovering host systems. -func getHostCommands() []DiscoveryCommand { - return []DiscoveryCommand{ - { - Name: "os_release", - Command: "cat /etc/os-release", - Description: "Operating system", - Categories: []string{"version", "config"}, - Optional: true, - }, - { - Name: "hostname", - Command: "hostname -f 2>/dev/null || hostname", - Description: "Full hostname", - Categories: []string{"config"}, - Optional: true, - }, - { - Name: "running_services", - Command: "systemctl list-units --type=service --state=running --no-pager 2>/dev/null | head -40", - Description: "Running services", - Categories: []string{"service"}, - Optional: true, - }, - { - Name: "listening_ports", - Command: "ss -tlnp 2>/dev/null | head -30", - Description: "Listening network ports", - Categories: []string{"port", "network"}, - Optional: true, - }, - { - Name: "docker_containers", - Command: "docker ps --format '{{.Names}}: {{.Image}} ({{.Status}})' 2>/dev/null | head -30 || echo 'no_docker'", - Description: "Docker containers on host", - Categories: []string{"container", "service"}, - Optional: true, - }, - { - Name: "proxmox_version", - Command: "pveversion 2>/dev/null || echo 'not_proxmox'", - Description: "Proxmox version if applicable", - Categories: []string{"version"}, - Optional: true, - }, - { - Name: "zfs_pools", - Command: "zpool list 2>/dev/null | head -10 || echo 'no_zfs'", - Description: "ZFS pools", - Categories: []string{"storage"}, - Optional: true, - }, - { - Name: "disk_usage", - Command: "df -h | head -20", - Description: "Disk usage", - Categories: []string{"storage"}, - Optional: true, - }, - { - Name: "hardware_info", - Command: "lscpu | head -20", - Description: "CPU information", - Categories: []string{"hardware"}, - Optional: true, - }, - { - Name: "memory_info", - Command: "free -h", - Description: "Memory information", - Categories: []string{"hardware"}, - Optional: true, - }, - } -} - -// BuildLXCCommand wraps a command for execution in an LXC container. -func BuildLXCCommand(vmid string, cmd string) string { - return fmt.Sprintf("pct exec %s -- sh -c %q", vmid, cmd) -} - -// BuildVMCommand wraps a command for execution in a VM via QEMU guest agent. -// Note: This requires the guest agent to be running. -func BuildVMCommand(vmid string, cmd string) string { - // For VMs, we use qm guest exec which requires the guest agent - return fmt.Sprintf("qm guest exec %s -- sh -c %q", vmid, cmd) -} - -// BuildDockerCommand wraps a command for execution in a Docker container. -func BuildDockerCommand(containerName string, cmd string) string { - return fmt.Sprintf("docker exec %s sh -c %q", containerName, cmd) -} - -// BuildNestedDockerCommand builds a command to run inside Docker on a VM/LXC. -func BuildNestedDockerCommand(vmid string, isLXC bool, containerName string, cmd string) string { - dockerCmd := BuildDockerCommand(containerName, cmd) - if isLXC { - return BuildLXCCommand(vmid, dockerCmd) - } - return BuildVMCommand(vmid, dockerCmd) -} - -// BuildK8sCommand builds a command to run in a Kubernetes pod. -func BuildK8sCommand(namespace, podName, containerName, cmd string) string { - if containerName != "" { - return fmt.Sprintf("kubectl exec -n %s %s -c %s -- sh -c %q", namespace, podName, containerName, cmd) - } - return fmt.Sprintf("kubectl exec -n %s %s -- sh -c %q", namespace, podName, cmd) -} - -// GetCLIAccessTemplate returns a CLI access template for a resource type. -func GetCLIAccessTemplate(resourceType ResourceType) string { - switch resourceType { - case ResourceTypeLXC: - return "pct exec {vmid} -- {command}" - case ResourceTypeVM: - return "qm guest exec {vmid} -- {command}" - case ResourceTypeDocker: - return "docker exec {container} {command}" - case ResourceTypeDockerLXC: - return "pct exec {vmid} -- docker exec {container} {command}" - case ResourceTypeDockerVM: - return "qm guest exec {vmid} -- docker exec {container} {command}" - case ResourceTypeK8s: - return "kubectl exec -n {namespace} {pod} -- {command}" - case ResourceTypeHost: - return "{command}" - default: - return "{command}" - } -} - -// FormatCLIAccess formats a CLI access string with actual values. -func FormatCLIAccess(resourceType ResourceType, vmid, containerName, namespace, podName string) string { - template := GetCLIAccessTemplate(resourceType) - result := template - - result = strings.ReplaceAll(result, "{vmid}", vmid) - result = strings.ReplaceAll(result, "{container}", containerName) - result = strings.ReplaceAll(result, "{namespace}", namespace) - result = strings.ReplaceAll(result, "{pod}", podName) - - return result -} diff --git a/internal/aidiscovery/commands_test.go b/internal/aidiscovery/commands_test.go deleted file mode 100644 index eeac3a030..000000000 --- a/internal/aidiscovery/commands_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package aidiscovery - -import ( - "strings" - "testing" -) - -func TestCommandsAndTemplates(t *testing.T) { - resourceTypes := []ResourceType{ - ResourceTypeLXC, - ResourceTypeVM, - ResourceTypeDocker, - ResourceTypeDockerVM, - ResourceTypeDockerLXC, - ResourceTypeK8s, - ResourceTypeHost, - } - - for _, rt := range resourceTypes { - cmds := GetCommandsForResource(rt) - if len(cmds) == 0 { - t.Fatalf("expected commands for %s", rt) - } - } - - if len(GetCommandsForResource(ResourceType("unknown"))) != 0 { - t.Fatalf("expected no commands for unknown resource type") - } - - if !strings.Contains(BuildLXCCommand("101", "echo hi"), "pct exec 101") { - t.Fatalf("unexpected LXC command") - } - if !strings.Contains(BuildVMCommand("101", "echo hi"), "qm guest exec 101") { - t.Fatalf("unexpected VM command") - } - if !strings.Contains(BuildDockerCommand("web", "echo hi"), "docker exec web") { - t.Fatalf("unexpected docker command") - } - - nestedLXC := BuildNestedDockerCommand("201", true, "web", "echo hi") - if !strings.Contains(nestedLXC, "pct exec 201") || !strings.Contains(nestedLXC, "docker exec web") { - t.Fatalf("unexpected nested LXC command: %s", nestedLXC) - } - - nestedVM := BuildNestedDockerCommand("301", false, "web", "echo hi") - if !strings.Contains(nestedVM, "qm guest exec 301") || !strings.Contains(nestedVM, "docker exec web") { - t.Fatalf("unexpected nested VM command: %s", nestedVM) - } - - withContainer := BuildK8sCommand("default", "pod", "app", "echo hi") - if !strings.Contains(withContainer, "-c app") || !strings.Contains(withContainer, "kubectl exec") { - t.Fatalf("unexpected k8s command: %s", withContainer) - } - - withoutContainer := BuildK8sCommand("default", "pod", "", "echo hi") - if strings.Contains(withoutContainer, "-c app") { - t.Fatalf("unexpected container selector: %s", withoutContainer) - } - - template := GetCLIAccessTemplate(ResourceTypeK8s) - if !strings.Contains(template, "{namespace}") || !strings.Contains(template, "{pod}") { - t.Fatalf("unexpected template: %s", template) - } - - for _, rt := range resourceTypes { - if tmpl := GetCLIAccessTemplate(rt); tmpl == "" { - t.Fatalf("expected template for %s", rt) - } - } - if tmpl := GetCLIAccessTemplate(ResourceType("unknown")); tmpl != "{command}" { - t.Fatalf("unexpected default template: %s", tmpl) - } - - formatted := FormatCLIAccess(ResourceTypeK8s, "101", "container", "default", "pod") - if !strings.Contains(formatted, "default") || !strings.Contains(formatted, "pod") { - t.Fatalf("unexpected formatted access: %s", formatted) - } -} diff --git a/internal/aidiscovery/deep_scanner.go b/internal/aidiscovery/deep_scanner.go deleted file mode 100644 index 291ceea07..000000000 --- a/internal/aidiscovery/deep_scanner.go +++ /dev/null @@ -1,378 +0,0 @@ -package aidiscovery - -import ( - "context" - "fmt" - "sync" - "time" - - "github.com/google/uuid" - "github.com/rs/zerolog/log" -) - -// CommandExecutor executes commands on infrastructure. -type CommandExecutor interface { - ExecuteCommand(ctx context.Context, agentID string, cmd ExecuteCommandPayload) (*CommandResultPayload, error) - GetConnectedAgents() []ConnectedAgent - IsAgentConnected(agentID string) bool -} - -// ExecuteCommandPayload mirrors agentexec.ExecuteCommandPayload -type ExecuteCommandPayload struct { - RequestID string `json:"request_id"` - Command string `json:"command"` - TargetType string `json:"target_type"` // "host", "container", "vm" - TargetID string `json:"target_id,omitempty"` // VMID for container/VM - Timeout int `json:"timeout,omitempty"` -} - -// CommandResultPayload mirrors agentexec.CommandResultPayload -type CommandResultPayload struct { - RequestID string `json:"request_id"` - Success bool `json:"success"` - Stdout string `json:"stdout,omitempty"` - Stderr string `json:"stderr,omitempty"` - ExitCode int `json:"exit_code"` - Error string `json:"error,omitempty"` - Duration int64 `json:"duration_ms"` -} - -// ConnectedAgent mirrors agentexec.ConnectedAgent -type ConnectedAgent struct { - AgentID string - Hostname string - Version string - Platform string - Tags []string - ConnectedAt time.Time -} - -// DeepScanner runs discovery commands on resources. -type DeepScanner struct { - executor CommandExecutor - mu sync.RWMutex - progress map[string]*DiscoveryProgress // resourceID -> progress - maxParallel int - timeout time.Duration -} - -// NewDeepScanner creates a new deep scanner. -func NewDeepScanner(executor CommandExecutor) *DeepScanner { - return &DeepScanner{ - executor: executor, - progress: make(map[string]*DiscoveryProgress), - maxParallel: 3, // Run up to 3 commands in parallel per resource - timeout: 30 * time.Second, - } -} - -// ScanResult contains the results of a deep scan. -type ScanResult struct { - ResourceType ResourceType - ResourceID string - HostID string - Hostname string - CommandOutputs map[string]string - Errors map[string]string - StartedAt time.Time - CompletedAt time.Time -} - -// Scan runs discovery commands on a resource and returns the outputs. -func (s *DeepScanner) Scan(ctx context.Context, req DiscoveryRequest) (*ScanResult, error) { - resourceID := MakeResourceID(req.ResourceType, req.HostID, req.ResourceID) - - // Initialize progress - s.mu.Lock() - s.progress[resourceID] = &DiscoveryProgress{ - ResourceID: resourceID, - Status: DiscoveryStatusRunning, - CurrentStep: "initializing", - StartedAt: time.Now(), - } - s.mu.Unlock() - - defer func() { - s.mu.Lock() - delete(s.progress, resourceID) - s.mu.Unlock() - }() - - result := &ScanResult{ - ResourceType: req.ResourceType, - ResourceID: req.ResourceID, - HostID: req.HostID, - Hostname: req.Hostname, - CommandOutputs: make(map[string]string), - Errors: make(map[string]string), - StartedAt: time.Now(), - } - - // Check if we have an agent for this host - if s.executor == nil { - return nil, fmt.Errorf("no command executor available") - } - - // Find the agent for this host - agentID := s.findAgentForHost(req.HostID, req.Hostname) - if agentID == "" { - return nil, fmt.Errorf("no connected agent for host %s (%s)", req.HostID, req.Hostname) - } - - // Get commands for this resource type - commands := GetCommandsForResource(req.ResourceType) - if len(commands) == 0 { - return nil, fmt.Errorf("no commands defined for resource type %s", req.ResourceType) - } - - // Update progress - s.mu.Lock() - if prog, ok := s.progress[resourceID]; ok { - prog.TotalSteps = len(commands) - prog.CurrentStep = "running commands" - } - s.mu.Unlock() - - // Run commands with limited parallelism - semaphore := make(chan struct{}, s.maxParallel) - var wg sync.WaitGroup - var mu sync.Mutex - - for _, cmd := range commands { - wg.Add(1) - go func(cmd DiscoveryCommand) { - defer wg.Done() - - select { - case semaphore <- struct{}{}: - defer func() { <-semaphore }() - case <-ctx.Done(): - return - } - - // Build the actual command to run - actualCmd := s.buildCommand(req.ResourceType, req.ResourceID, cmd.Command) - - // Execute the command - cmdCtx, cancel := context.WithTimeout(ctx, s.timeout) - defer cancel() - - cmdResult, err := s.executor.ExecuteCommand(cmdCtx, agentID, ExecuteCommandPayload{ - RequestID: uuid.New().String(), - Command: actualCmd, - TargetType: s.getTargetType(req.ResourceType), - TargetID: req.ResourceID, - Timeout: cmd.Timeout, - }) - - mu.Lock() - defer mu.Unlock() - - if err != nil { - if !cmd.Optional { - result.Errors[cmd.Name] = err.Error() - } - log.Debug(). - Err(err). - Str("command", cmd.Name). - Str("resource", resourceID). - Msg("Command failed during discovery") - return - } - - if cmdResult != nil { - output := cmdResult.Stdout - if cmdResult.Stderr != "" && output != "" { - output += "\n--- stderr ---\n" + cmdResult.Stderr - } else if cmdResult.Stderr != "" { - output = cmdResult.Stderr - } - - if output != "" { - result.CommandOutputs[cmd.Name] = output - } - - if !cmdResult.Success && cmdResult.Error != "" && !cmd.Optional { - result.Errors[cmd.Name] = cmdResult.Error - } - } - - // Update progress - s.mu.Lock() - if prog, ok := s.progress[resourceID]; ok { - prog.CompletedSteps++ - } - s.mu.Unlock() - }(cmd) - } - - wg.Wait() - result.CompletedAt = time.Now() - - log.Info(). - Str("resource", resourceID). - Int("outputs", len(result.CommandOutputs)). - Int("errors", len(result.Errors)). - Dur("duration", result.CompletedAt.Sub(result.StartedAt)). - Msg("Deep scan completed") - - return result, nil -} - -// buildCommand wraps the command appropriately for the resource type. -// NOTE: For LXC/VM, the agent handles wrapping via pct exec / qm guest exec -// based on TargetType, so we don't wrap here. We only wrap for Docker containers -// since Docker isn't a recognized TargetType in the agent. -func (s *DeepScanner) buildCommand(resourceType ResourceType, resourceID string, cmd string) string { - switch resourceType { - case ResourceTypeLXC: - // Agent wraps with pct exec based on TargetType="container" - return cmd - case ResourceTypeVM: - // Agent wraps with qm guest exec based on TargetType="vm" - return cmd - case ResourceTypeDocker: - // Docker needs wrapping here since agent doesn't handle it - return BuildDockerCommand(resourceID, cmd) - case ResourceTypeHost: - // Commands run directly on host - return cmd - case ResourceTypeDockerLXC: - // Docker inside LXC - agent wraps with pct exec, we just add docker exec - // resourceID format: "vmid:container_name" - parts := splitResourceID(resourceID) - if len(parts) >= 2 { - return BuildDockerCommand(parts[1], cmd) - } - return cmd - case ResourceTypeDockerVM: - // Docker inside VM - agent wraps with qm guest exec, we just add docker exec - parts := splitResourceID(resourceID) - if len(parts) >= 2 { - return BuildDockerCommand(parts[1], cmd) - } - return cmd - default: - return cmd - } -} - -// getTargetType returns the target type for the agent execution payload. -func (s *DeepScanner) getTargetType(resourceType ResourceType) string { - switch resourceType { - case ResourceTypeLXC: - return "container" - case ResourceTypeVM: - return "vm" - case ResourceTypeDocker: - return "host" // Docker commands run on host via docker exec - case ResourceTypeHost: - return "host" - default: - return "host" - } -} - -// findAgentForHost finds the agent ID for a given host. -func (s *DeepScanner) findAgentForHost(hostID, hostname string) string { - agents := s.executor.GetConnectedAgents() - - // First try exact match on agent ID - for _, agent := range agents { - if agent.AgentID == hostID { - return agent.AgentID - } - } - - // Then try hostname match - for _, agent := range agents { - if agent.Hostname == hostname || agent.Hostname == hostID { - return agent.AgentID - } - } - - // If only one agent connected, use it - if len(agents) == 1 { - return agents[0].AgentID - } - - return "" -} - -// GetProgress returns the current progress of a scan. -func (s *DeepScanner) GetProgress(resourceID string) *DiscoveryProgress { - s.mu.RLock() - defer s.mu.RUnlock() - if prog, ok := s.progress[resourceID]; ok { - return prog - } - return nil -} - -// IsScanning returns whether a resource is currently being scanned. -func (s *DeepScanner) IsScanning(resourceID string) bool { - s.mu.RLock() - defer s.mu.RUnlock() - _, ok := s.progress[resourceID] - return ok -} - -// splitResourceID splits a compound resource ID (e.g., "101:container_name"). -func splitResourceID(id string) []string { - var parts []string - start := 0 - for i, c := range id { - if c == ':' { - parts = append(parts, id[start:i]) - start = i + 1 - } - } - if start < len(id) { - parts = append(parts, id[start:]) - } - return parts -} - -// ScanDocker runs discovery on Docker containers via the host. -func (s *DeepScanner) ScanDocker(ctx context.Context, hostID, hostname, containerName string) (*ScanResult, error) { - req := DiscoveryRequest{ - ResourceType: ResourceTypeDocker, - ResourceID: containerName, - HostID: hostID, - Hostname: hostname, - } - return s.Scan(ctx, req) -} - -// ScanLXC runs discovery on an LXC container. -func (s *DeepScanner) ScanLXC(ctx context.Context, hostID, hostname, vmid string) (*ScanResult, error) { - req := DiscoveryRequest{ - ResourceType: ResourceTypeLXC, - ResourceID: vmid, - HostID: hostID, - Hostname: hostname, - } - return s.Scan(ctx, req) -} - -// ScanVM runs discovery on a VM via QEMU guest agent. -func (s *DeepScanner) ScanVM(ctx context.Context, hostID, hostname, vmid string) (*ScanResult, error) { - req := DiscoveryRequest{ - ResourceType: ResourceTypeVM, - ResourceID: vmid, - HostID: hostID, - Hostname: hostname, - } - return s.Scan(ctx, req) -} - -// ScanHost runs discovery on a host system. -func (s *DeepScanner) ScanHost(ctx context.Context, hostID, hostname string) (*ScanResult, error) { - req := DiscoveryRequest{ - ResourceType: ResourceTypeHost, - ResourceID: hostID, - HostID: hostID, - Hostname: hostname, - } - return s.Scan(ctx, req) -} diff --git a/internal/aidiscovery/deep_scanner_test.go b/internal/aidiscovery/deep_scanner_test.go deleted file mode 100644 index fc5ad780b..000000000 --- a/internal/aidiscovery/deep_scanner_test.go +++ /dev/null @@ -1,325 +0,0 @@ -package aidiscovery - -import ( - "context" - "strings" - "sync" - "testing" - "time" -) - -type stubExecutor struct { - mu sync.Mutex - commands []string - agents []ConnectedAgent -} - -func (s *stubExecutor) ExecuteCommand(ctx context.Context, agentID string, cmd ExecuteCommandPayload) (*CommandResultPayload, error) { - s.mu.Lock() - s.commands = append(s.commands, cmd.Command) - s.mu.Unlock() - - if err := ctx.Err(); err != nil { - return nil, err - } - - if strings.Contains(cmd.Command, "docker ps -a") { - return &CommandResultPayload{ - RequestID: cmd.RequestID, - Success: false, - Error: "boom", - }, nil - } - - return &CommandResultPayload{ - RequestID: cmd.RequestID, - Success: true, - Stdout: cmd.Command, - Duration: 5, - }, nil -} - -func (s *stubExecutor) GetConnectedAgents() []ConnectedAgent { - return s.agents -} - -func (s *stubExecutor) IsAgentConnected(agentID string) bool { - for _, agent := range s.agents { - if agent.AgentID == agentID { - return true - } - } - return false -} - -type outputExecutor struct{} - -func (outputExecutor) ExecuteCommand(ctx context.Context, agentID string, cmd ExecuteCommandPayload) (*CommandResultPayload, error) { - switch { - case strings.Contains(cmd.Command, "docker ps -a"): - return &CommandResultPayload{Success: true, Stdout: "out", Stderr: "err"}, nil - case strings.Contains(cmd.Command, "docker images"): - return &CommandResultPayload{Success: true, Stderr: "err-only"}, nil - default: - return &CommandResultPayload{Success: true}, nil - } -} - -func (outputExecutor) GetConnectedAgents() []ConnectedAgent { - return []ConnectedAgent{{AgentID: "host1", Hostname: "host1"}} -} - -func (outputExecutor) IsAgentConnected(string) bool { return true } - -type errorExecutor struct{} - -func (errorExecutor) ExecuteCommand(ctx context.Context, agentID string, cmd ExecuteCommandPayload) (*CommandResultPayload, error) { - return nil, context.DeadlineExceeded -} - -func (errorExecutor) GetConnectedAgents() []ConnectedAgent { - return []ConnectedAgent{{AgentID: "host1", Hostname: "host1"}} -} - -func (errorExecutor) IsAgentConnected(string) bool { return true } - -func TestDeepScanner_Scan_NestedDockerCommands(t *testing.T) { - exec := &stubExecutor{ - agents: []ConnectedAgent{ - {AgentID: "host1", Hostname: "host1", ConnectedAt: time.Now()}, - }, - } - scanner := NewDeepScanner(exec) - - result, err := scanner.Scan(context.Background(), DiscoveryRequest{ - ResourceType: ResourceTypeDockerVM, - ResourceID: "101:web", - HostID: "host1", - Hostname: "host1", - }) - if err != nil { - t.Fatalf("Scan error: %v", err) - } - if len(result.CommandOutputs) == 0 { - t.Fatalf("expected command outputs") - } - if _, ok := result.Errors["docker_containers"]; !ok { - t.Fatalf("expected docker_containers error, got %#v", result.Errors) - } - - exec.mu.Lock() - defer exec.mu.Unlock() - foundWrapped := false - for _, cmd := range exec.commands { - if strings.Contains(cmd, "qm guest exec 101") && strings.Contains(cmd, "docker exec web") { - foundWrapped = true - break - } - } - if !foundWrapped { - t.Fatalf("expected nested docker command, got %#v", exec.commands) - } -} - -func TestDeepScanner_FindAgentAndTargetType(t *testing.T) { - exec := &stubExecutor{ - agents: []ConnectedAgent{ - {AgentID: "a1", Hostname: "node1"}, - {AgentID: "a2", Hostname: "node2"}, - }, - } - scanner := NewDeepScanner(exec) - - if got := scanner.findAgentForHost("a2", ""); got != "a2" { - t.Fatalf("expected direct agent match, got %s", got) - } - if got := scanner.findAgentForHost("node1", "node1"); got != "a1" { - t.Fatalf("expected hostname match, got %s", got) - } - - exec.agents = []ConnectedAgent{{AgentID: "solo", Hostname: "only"}} - if got := scanner.findAgentForHost("missing", "missing"); got != "solo" { - t.Fatalf("expected single agent fallback, got %s", got) - } - exec.agents = nil - if got := scanner.findAgentForHost("missing", "missing"); got != "" { - t.Fatalf("expected no agent, got %s", got) - } - - if scanner.getTargetType(ResourceTypeLXC) != "container" { - t.Fatalf("unexpected target type for lxc") - } - if scanner.getTargetType(ResourceTypeVM) != "vm" { - t.Fatalf("unexpected target type for vm") - } - if scanner.getTargetType(ResourceTypeDocker) != "host" { - t.Fatalf("unexpected target type for docker") - } - if scanner.getTargetType(ResourceTypeHost) != "host" { - t.Fatalf("unexpected target type for host") - } -} - -func TestSplitResourceID(t *testing.T) { - parts := splitResourceID("101:web:extra") - if len(parts) != 3 || parts[0] != "101" || parts[1] != "web" || parts[2] != "extra" { - t.Fatalf("unexpected parts: %#v", parts) - } -} - -func TestDeepScanner_BuildCommandAndProgress(t *testing.T) { - scanner := NewDeepScanner(&stubExecutor{}) - - if cmd := scanner.buildCommand(ResourceTypeLXC, "101", "echo hi"); !strings.Contains(cmd, "pct exec 101") { - t.Fatalf("unexpected lxc command: %s", cmd) - } - if cmd := scanner.buildCommand(ResourceTypeVM, "101", "echo hi"); cmd != "echo hi" { - t.Fatalf("unexpected vm command: %s", cmd) - } - if cmd := scanner.buildCommand(ResourceTypeDocker, "web", "echo hi"); !strings.Contains(cmd, "docker exec web") { - t.Fatalf("unexpected docker command: %s", cmd) - } - if cmd := scanner.buildCommand(ResourceTypeHost, "host", "echo hi"); cmd != "echo hi" { - t.Fatalf("unexpected host command: %s", cmd) - } - - dockerLXC := scanner.buildCommand(ResourceTypeDockerLXC, "201:web", "echo hi") - if !strings.Contains(dockerLXC, "pct exec 201") || !strings.Contains(dockerLXC, "docker exec web") { - t.Fatalf("unexpected docker lxc command: %s", dockerLXC) - } - if cmd := scanner.buildCommand(ResourceTypeDockerLXC, "bad", "echo hi"); cmd != "echo hi" { - t.Fatalf("expected fallback lxc command, got %s", cmd) - } - dockerVM := scanner.buildCommand(ResourceTypeDockerVM, "301:web", "echo hi") - if !strings.Contains(dockerVM, "qm guest exec 301") || !strings.Contains(dockerVM, "docker exec web") { - t.Fatalf("unexpected docker vm command: %s", dockerVM) - } - if cmd := scanner.buildCommand(ResourceTypeDockerVM, "bad", "echo hi"); cmd != "echo hi" { - t.Fatalf("expected fallback command, got %s", cmd) - } - if cmd := scanner.buildCommand(ResourceType("unknown"), "id", "echo hi"); cmd != "echo hi" { - t.Fatalf("expected default command, got %s", cmd) - } - - scanner.progress["id"] = &DiscoveryProgress{ResourceID: "id"} - if scanner.GetProgress("id") == nil { - t.Fatalf("expected progress") - } - if !scanner.IsScanning("id") { - t.Fatalf("expected IsScanning true") - } - if scanner.GetProgress("missing") != nil { - t.Fatalf("expected nil progress") - } - if scanner.IsScanning("missing") { - t.Fatalf("expected IsScanning false") - } - - noExec := NewDeepScanner(nil) - if _, err := noExec.ScanHost(context.Background(), "host1", "host1"); err == nil { - t.Fatalf("expected error without executor") - } -} - -func TestDeepScanner_ScanWrappers(t *testing.T) { - exec := &stubExecutor{ - agents: []ConnectedAgent{{AgentID: "host1", Hostname: "host1"}}, - } - scanner := NewDeepScanner(exec) - scanner.maxParallel = 1 - - if _, err := scanner.ScanDocker(context.Background(), "host1", "host1", "web"); err != nil { - t.Fatalf("ScanDocker error: %v", err) - } - if _, err := scanner.ScanLXC(context.Background(), "host1", "host1", "101"); err != nil { - t.Fatalf("ScanLXC error: %v", err) - } - if _, err := scanner.ScanVM(context.Background(), "host1", "host1", "102"); err != nil { - t.Fatalf("ScanVM error: %v", err) - } -} - -func TestDeepScanner_ScanErrors(t *testing.T) { - exec := &stubExecutor{ - agents: []ConnectedAgent{{AgentID: "host1", Hostname: "host1"}}, - } - scanner := NewDeepScanner(exec) - if _, err := scanner.Scan(context.Background(), DiscoveryRequest{ - ResourceType: ResourceType("unknown"), - ResourceID: "id", - HostID: "host1", - Hostname: "host1", - }); err == nil { - t.Fatalf("expected error for unknown resource type") - } - - exec.agents = nil - if _, err := scanner.Scan(context.Background(), DiscoveryRequest{ - ResourceType: ResourceTypeDocker, - ResourceID: "web", - HostID: "host1", - Hostname: "host1", - }); err == nil { - t.Fatalf("expected error for missing agent") - } -} - -func TestDeepScanner_OutputHandling(t *testing.T) { - exec := outputExecutor{} - scanner := NewDeepScanner(exec) - scanner.maxParallel = 1 - - result, err := scanner.Scan(context.Background(), DiscoveryRequest{ - ResourceType: ResourceTypeDockerVM, - ResourceID: "101:web", - HostID: "host1", - Hostname: "host1", - }) - if err != nil { - t.Fatalf("Scan error: %v", err) - } - if out := result.CommandOutputs["docker_containers"]; !strings.Contains(out, "--- stderr ---") { - t.Fatalf("expected combined stderr output, got %s", out) - } - if out := result.CommandOutputs["docker_images"]; out != "err-only" { - t.Fatalf("expected stderr-only output, got %s", out) - } -} - -func TestDeepScanner_CommandErrorHandling(t *testing.T) { - scanner := NewDeepScanner(errorExecutor{}) - scanner.maxParallel = 1 - - result, err := scanner.Scan(context.Background(), DiscoveryRequest{ - ResourceType: ResourceTypeDockerVM, - ResourceID: "101:web", - HostID: "host1", - Hostname: "host1", - }) - if err != nil { - t.Fatalf("Scan error: %v", err) - } - if _, ok := result.Errors["docker_containers"]; !ok { - t.Fatalf("expected error for non-optional command") - } -} - -func TestDeepScanner_ScanCanceledContext(t *testing.T) { - exec := &stubExecutor{ - agents: []ConnectedAgent{{AgentID: "host1", Hostname: "host1"}}, - } - scanner := NewDeepScanner(exec) - scanner.maxParallel = 0 - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - if _, err := scanner.Scan(ctx, DiscoveryRequest{ - ResourceType: ResourceTypeDockerVM, - ResourceID: "101:web", - HostID: "host1", - Hostname: "host1", - }); err != nil { - t.Fatalf("Scan error: %v", err) - } -} diff --git a/internal/aidiscovery/formatters.go b/internal/aidiscovery/formatters.go deleted file mode 100644 index 889af6a5b..000000000 --- a/internal/aidiscovery/formatters.go +++ /dev/null @@ -1,337 +0,0 @@ -package aidiscovery - -import ( - "fmt" - "strings" - "time" -) - -// FormatForAIContext formats discoveries for inclusion in AI prompts. -// This provides context about resources for Patrol, Investigation, and Chat. -func FormatForAIContext(discoveries []*ResourceDiscovery) string { - if len(discoveries) == 0 { - return "" - } - - var sb strings.Builder - sb.WriteString("## Infrastructure Discovery\n\n") - sb.WriteString("The following has been discovered about the affected resources:\n\n") - - for _, d := range discoveries { - sb.WriteString(formatSingleDiscovery(d)) - sb.WriteString("\n") - } - - sb.WriteString("\n**IMPORTANT:** Use the CLI access methods shown above. For example:\n") - sb.WriteString("- For LXC containers, use `pct exec -- `\n") - sb.WriteString("- For VMs with guest agent, use `qm guest exec -- `\n") - sb.WriteString("- For Docker containers, use `docker exec `\n") - - return sb.String() -} - -// FormatSingleForAIContext formats a single discovery for AI context. -func FormatSingleForAIContext(d *ResourceDiscovery) string { - if d == nil { - return "" - } - return formatSingleDiscovery(d) -} - -// formatSingleDiscovery formats a single discovery entry. -func formatSingleDiscovery(d *ResourceDiscovery) string { - var sb strings.Builder - - // Header with service info - sb.WriteString(fmt.Sprintf("### %s (%s)\n", d.ServiceName, d.ID)) - sb.WriteString(fmt.Sprintf("- **Type:** %s\n", d.ResourceType)) - sb.WriteString(fmt.Sprintf("- **Host:** %s\n", d.Hostname)) - - if d.ServiceVersion != "" { - sb.WriteString(fmt.Sprintf("- **Version:** %s\n", d.ServiceVersion)) - } - - if d.Category != "" && d.Category != CategoryUnknown { - sb.WriteString(fmt.Sprintf("- **Category:** %s\n", d.Category)) - } - - // CLI access (most important for remediation) - if d.CLIAccess != "" { - sb.WriteString(fmt.Sprintf("- **CLI Access:** `%s`\n", d.CLIAccess)) - } - - // Config and data paths - if len(d.ConfigPaths) > 0 { - sb.WriteString(fmt.Sprintf("- **Config Paths:** %s\n", strings.Join(d.ConfigPaths, ", "))) - } - if len(d.DataPaths) > 0 { - sb.WriteString(fmt.Sprintf("- **Data Paths:** %s\n", strings.Join(d.DataPaths, ", "))) - } - - // Ports - if len(d.Ports) > 0 { - var ports []string - for _, p := range d.Ports { - ports = append(ports, fmt.Sprintf("%d/%s", p.Port, p.Protocol)) - } - sb.WriteString(fmt.Sprintf("- **Ports:** %s\n", strings.Join(ports, ", "))) - } - - // Important facts - importantFacts := filterImportantFacts(d.Facts) - if len(importantFacts) > 0 { - sb.WriteString("- **Key Facts:**\n") - for _, f := range importantFacts { - sb.WriteString(fmt.Sprintf(" - %s: %s\n", f.Key, f.Value)) - } - } - - // User notes (critical for context) - if d.UserNotes != "" { - sb.WriteString(fmt.Sprintf("- **User Notes:** %s\n", d.UserNotes)) - } - - return sb.String() -} - -// filterImportantFacts returns the most relevant facts for AI context. -func filterImportantFacts(facts []DiscoveryFact) []DiscoveryFact { - var important []DiscoveryFact - - // Priority categories - priorityCategories := map[FactCategory]bool{ - FactCategoryHardware: true, // GPU, TPU - FactCategoryDependency: true, // MQTT, database connections - FactCategorySecurity: true, // Auth info - FactCategoryVersion: true, // Version info - } - - for _, f := range facts { - if priorityCategories[f.Category] && f.Confidence >= 0.7 { - important = append(important, f) - } - } - - // Limit to top 5 facts - if len(important) > 5 { - important = important[:5] - } - - return important -} - -// FormatDiscoverySummary formats a summary of all discoveries. -func FormatDiscoverySummary(discoveries []*ResourceDiscovery) string { - if len(discoveries) == 0 { - return "No infrastructure discovery data available." - } - - var sb strings.Builder - sb.WriteString(fmt.Sprintf("Infrastructure Discovery Summary (%d resources):\n\n", len(discoveries))) - - // Group by resource type - byType := make(map[ResourceType][]*ResourceDiscovery) - for _, d := range discoveries { - byType[d.ResourceType] = append(byType[d.ResourceType], d) - } - - for rt, ds := range byType { - sb.WriteString(fmt.Sprintf("**%s** (%d):\n", rt, len(ds))) - for _, d := range ds { - confidence := "" - if d.Confidence >= 0.9 { - confidence = " [high confidence]" - } else if d.Confidence >= 0.7 { - confidence = " [medium confidence]" - } - sb.WriteString(fmt.Sprintf(" - %s: %s%s\n", d.ResourceID, d.ServiceName, confidence)) - } - sb.WriteString("\n") - } - - return sb.String() -} - -// FormatForRemediation formats discovery specifically for remediation context. -func FormatForRemediation(d *ResourceDiscovery) string { - if d == nil { - return "" - } - - var sb strings.Builder - sb.WriteString("## Resource Context for Remediation\n\n") - - sb.WriteString(fmt.Sprintf("**Resource:** %s (%s)\n", d.ServiceName, d.ID)) - sb.WriteString(fmt.Sprintf("**Type:** %s on %s\n\n", d.ResourceType, d.Hostname)) - - // CLI access is most critical - if d.CLIAccess != "" { - sb.WriteString("### How to Execute Commands\n") - sb.WriteString(fmt.Sprintf("```\n%s\n```\n\n", d.CLIAccess)) - } - - // Service-specific info - if d.ServiceType != "" { - sb.WriteString(fmt.Sprintf("**Service:** %s", d.ServiceType)) - if d.ServiceVersion != "" { - sb.WriteString(fmt.Sprintf(" v%s", d.ServiceVersion)) - } - sb.WriteString("\n\n") - } - - // Config paths for potential fixes - if len(d.ConfigPaths) > 0 { - sb.WriteString("### Configuration Files\n") - for _, p := range d.ConfigPaths { - sb.WriteString(fmt.Sprintf("- `%s`\n", p)) - } - sb.WriteString("\n") - } - - // User notes may contain important context - if d.UserNotes != "" { - sb.WriteString("### User Notes\n") - sb.WriteString(d.UserNotes) - sb.WriteString("\n\n") - } - - // Hardware info for special considerations - for _, f := range d.Facts { - if f.Category == FactCategoryHardware { - sb.WriteString(fmt.Sprintf("**Hardware:** %s = %s\n", f.Key, f.Value)) - } - } - - return sb.String() -} - -// FormatDiscoveryAge returns a human-readable age string. -func FormatDiscoveryAge(d *ResourceDiscovery) string { - if d == nil || d.UpdatedAt.IsZero() { - return "unknown" - } - - age := time.Since(d.UpdatedAt) - switch { - case age < time.Minute: - return "just now" - case age < time.Hour: - mins := int(age.Minutes()) - if mins == 1 { - return "1 minute ago" - } - return fmt.Sprintf("%d minutes ago", mins) - case age < 24*time.Hour: - hours := int(age.Hours()) - if hours == 1 { - return "1 hour ago" - } - return fmt.Sprintf("%d hours ago", hours) - default: - days := int(age.Hours() / 24) - if days == 1 { - return "1 day ago" - } - return fmt.Sprintf("%d days ago", days) - } -} - -// GetCLIExample returns an example CLI command for the resource. -func GetCLIExample(d *ResourceDiscovery, exampleCmd string) string { - if d == nil || d.CLIAccess == "" { - return "" - } - - // Replace the placeholder with the example command - cli := d.CLIAccess - cli = strings.ReplaceAll(cli, "...", exampleCmd) - cli = strings.ReplaceAll(cli, "{command}", exampleCmd) - - return cli -} - -// FormatFactsTable formats facts as a simple table. -func FormatFactsTable(facts []DiscoveryFact) string { - if len(facts) == 0 { - return "" - } - - var sb strings.Builder - sb.WriteString("| Category | Key | Value |\n") - sb.WriteString("|----------|-----|-------|\n") - - for _, f := range facts { - value := f.Value - if len(value) > 50 { - value = value[:47] + "..." - } - sb.WriteString(fmt.Sprintf("| %s | %s | %s |\n", f.Category, f.Key, value)) - } - - return sb.String() -} - -// BuildResourceContextForPatrol builds context for Patrol findings. -func BuildResourceContextForPatrol(store *Store, resourceIDs []string) string { - if store == nil || len(resourceIDs) == 0 { - return "" - } - - discoveries, err := store.GetMultiple(resourceIDs) - if err != nil || len(discoveries) == 0 { - return "" - } - - return FormatForAIContext(discoveries) -} - -// ToJSON converts a discovery to a JSON-friendly map. -func ToJSON(d *ResourceDiscovery) map[string]any { - if d == nil { - return nil - } - - facts := make([]map[string]any, 0, len(d.Facts)) - for _, f := range d.Facts { - facts = append(facts, map[string]any{ - "category": f.Category, - "key": f.Key, - "value": f.Value, - "source": f.Source, - "confidence": f.Confidence, - }) - } - - ports := make([]map[string]any, 0, len(d.Ports)) - for _, p := range d.Ports { - ports = append(ports, map[string]any{ - "port": p.Port, - "protocol": p.Protocol, - "process": p.Process, - "address": p.Address, - }) - } - - return map[string]any{ - "id": d.ID, - "resource_type": d.ResourceType, - "resource_id": d.ResourceID, - "host_id": d.HostID, - "hostname": d.Hostname, - "service_type": d.ServiceType, - "service_name": d.ServiceName, - "service_version": d.ServiceVersion, - "category": d.Category, - "cli_access": d.CLIAccess, - "facts": facts, - "config_paths": d.ConfigPaths, - "data_paths": d.DataPaths, - "ports": ports, - "user_notes": d.UserNotes, - "confidence": d.Confidence, - "ai_reasoning": d.AIReasoning, - "discovered_at": d.DiscoveredAt, - "updated_at": d.UpdatedAt, - "scan_duration": d.ScanDuration, - } -} diff --git a/internal/aidiscovery/formatters_test.go b/internal/aidiscovery/formatters_test.go deleted file mode 100644 index c6841e785..000000000 --- a/internal/aidiscovery/formatters_test.go +++ /dev/null @@ -1,195 +0,0 @@ -package aidiscovery - -import ( - "strings" - "testing" - "time" -) - -func TestFormattersAndTables(t *testing.T) { - if FormatForAIContext(nil) != "" { - t.Fatalf("expected empty context for nil discoveries") - } - - discovery := &ResourceDiscovery{ - ID: MakeResourceID(ResourceTypeDocker, "host1", "app"), - ResourceType: ResourceTypeDocker, - ResourceID: "app", - HostID: "host1", - Hostname: "host1", - ServiceType: "app", - ServiceName: "App Service", - ServiceVersion: "1.0", - Category: CategoryWebServer, - CLIAccess: "docker exec app ...", - ConfigPaths: []string{"/etc/app/config.yml"}, - DataPaths: []string{"/var/lib/app"}, - Ports: []PortInfo{{Port: 80, Protocol: "tcp"}}, - UserNotes: "keepalive enabled", - Facts: []DiscoveryFact{ - {Category: FactCategoryHardware, Key: "gpu", Value: "nvidia", Confidence: 0.9}, - {Category: FactCategoryService, Key: "worker", Value: "enabled", Confidence: 0.9}, - }, - } - - ctx := FormatForAIContext([]*ResourceDiscovery{discovery}) - if !strings.Contains(ctx, "Infrastructure Discovery") || !strings.Contains(ctx, "App Service") { - t.Fatalf("unexpected context: %s", ctx) - } - if !strings.Contains(ctx, "docker exec") || !strings.Contains(ctx, "User Notes") { - t.Fatalf("missing expected fields in context") - } - - if FormatSingleForAIContext(nil) != "" { - t.Fatalf("expected empty string for nil discovery") - } - if !strings.Contains(FormatSingleForAIContext(discovery), "App Service") { - t.Fatalf("expected single discovery output") - } - - remediation := FormatForRemediation(discovery) - if !strings.Contains(remediation, "How to Execute Commands") || !strings.Contains(remediation, "Hardware") { - t.Fatalf("unexpected remediation output: %s", remediation) - } - if FormatForRemediation(nil) != "" { - t.Fatalf("expected empty remediation output for nil") - } - - example := GetCLIExample(discovery, "ls /") - if !strings.Contains(example, "ls /") { - t.Fatalf("unexpected cli example: %s", example) - } - if GetCLIExample(&ResourceDiscovery{}, "ls /") != "" { - t.Fatalf("expected empty example when cli access missing") - } - - table := FormatFactsTable([]DiscoveryFact{ - {Category: FactCategoryVersion, Key: "app", Value: strings.Repeat("x", 60)}, - }) - if !strings.Contains(table, "...") { - t.Fatalf("expected truncated table value: %s", table) - } - if FormatFactsTable(nil) != "" { - t.Fatalf("expected empty facts table for nil") - } - - jsonMap := ToJSON(discovery) - if jsonMap["service_name"] != "App Service" || jsonMap["resource_id"] != "app" { - t.Fatalf("unexpected json map: %#v", jsonMap) - } - if ToJSON(nil) != nil { - t.Fatalf("expected nil json map for nil discovery") - } -} - -func TestFormatDiscoverySummaryAndAge(t *testing.T) { - now := time.Now() - if FormatDiscoverySummary(nil) == "" { - t.Fatalf("expected summary text for empty list") - } - if FormatDiscoveryAge(nil) != "unknown" { - t.Fatalf("expected unknown age for nil") - } - if FormatDiscoveryAge(&ResourceDiscovery{}) != "unknown" { - t.Fatalf("expected unknown age for zero timestamp") - } - discoveries := []*ResourceDiscovery{ - { - ID: MakeResourceID(ResourceTypeVM, "node1", "101"), - ResourceType: ResourceTypeVM, - ResourceID: "101", - HostID: "node1", - ServiceName: "VM One", - Confidence: 0.95, - UpdatedAt: now.Add(-2 * time.Hour), - }, - { - ID: MakeResourceID(ResourceTypeDocker, "host1", "app"), - ResourceType: ResourceTypeDocker, - ResourceID: "app", - HostID: "host1", - ServiceName: "App", - Confidence: 0.75, - UpdatedAt: now.Add(-2 * 24 * time.Hour), - }, - } - - summary := FormatDiscoverySummary(discoveries) - if !strings.Contains(summary, "[high confidence]") || !strings.Contains(summary, "[medium confidence]") { - t.Fatalf("unexpected summary: %s", summary) - } - - tests := []struct { - name string - updated time.Time - expected string - }{ - {name: "just-now", updated: now.Add(-30 * time.Second), expected: "just now"}, - {name: "one-minute", updated: now.Add(-1 * time.Minute), expected: "1 minute ago"}, - {name: "minutes", updated: now.Add(-10 * time.Minute), expected: "10 minutes ago"}, - {name: "one-hour", updated: now.Add(-1 * time.Hour), expected: "1 hour ago"}, - {name: "hours", updated: now.Add(-2 * time.Hour), expected: "2 hours ago"}, - {name: "one-day", updated: now.Add(-24 * time.Hour), expected: "1 day ago"}, - {name: "days", updated: now.Add(-3 * 24 * time.Hour), expected: "3 days ago"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := FormatDiscoveryAge(&ResourceDiscovery{UpdatedAt: tt.updated}) - if got != tt.expected { - t.Fatalf("expected %s, got %s", tt.expected, got) - } - }) - } -} - -func TestBuildResourceContextForPatrol(t *testing.T) { - store, err := NewStore(t.TempDir()) - if err != nil { - t.Fatalf("NewStore error: %v", err) - } - store.crypto = nil - - discovery := &ResourceDiscovery{ - ID: MakeResourceID(ResourceTypeDocker, "host1", "app"), - ResourceType: ResourceTypeDocker, - ResourceID: "app", - HostID: "host1", - ServiceName: "App Service", - } - if err := store.Save(discovery); err != nil { - t.Fatalf("Save error: %v", err) - } - - ctx := BuildResourceContextForPatrol(store, []string{discovery.ID}) - if !strings.Contains(ctx, "App Service") { - t.Fatalf("unexpected patrol context: %s", ctx) - } - - if BuildResourceContextForPatrol(nil, []string{discovery.ID}) != "" { - t.Fatalf("expected empty context for nil store") - } - if BuildResourceContextForPatrol(store, nil) != "" { - t.Fatalf("expected empty context for empty ids") - } - if BuildResourceContextForPatrol(store, []string{"missing"}) != "" { - t.Fatalf("expected empty context for missing discoveries") - } -} - -func TestFilterImportantFactsLimit(t *testing.T) { - var facts []DiscoveryFact - for i := 0; i < 7; i++ { - facts = append(facts, DiscoveryFact{ - Category: FactCategoryVersion, - Key: "k", - Value: "v", - Confidence: 0.9, - }) - } - - important := filterImportantFacts(facts) - if len(important) != 5 { - t.Fatalf("expected 5 facts, got %d", len(important)) - } -} diff --git a/internal/aidiscovery/service.go b/internal/aidiscovery/service.go deleted file mode 100644 index a6def0c4a..000000000 --- a/internal/aidiscovery/service.go +++ /dev/null @@ -1,778 +0,0 @@ -// Package aidiscovery provides AI-powered infrastructure discovery capabilities. -// It discovers services, versions, configurations, and CLI access methods -// for VMs, LXCs, Docker containers, Kubernetes pods, and hosts. -package aidiscovery - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "sync" - "time" - - "github.com/rs/zerolog/log" -) - -// StateProvider provides access to the current infrastructure state. -type StateProvider interface { - GetState() StateSnapshot -} - -// StateSnapshot represents the infrastructure state. This mirrors models.StateSnapshot -// to avoid circular dependencies. -type StateSnapshot struct { - VMs []VM - Containers []Container - DockerHosts []DockerHost -} - -// VM represents a virtual machine. -type VM struct { - VMID int - Name string - Node string - Status string - Instance string -} - -// Container represents an LXC container. -type Container struct { - VMID int - Name string - Node string - Status string - Instance string -} - -// 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 -} - -// AIAnalyzer provides AI analysis capabilities for discovery. -type AIAnalyzer interface { - AnalyzeForDiscovery(ctx context.Context, prompt string) (string, error) -} - -// Service manages AI-powered infrastructure discovery. -type Service struct { - store *Store - scanner *DeepScanner - stateProvider StateProvider - aiAnalyzer AIAnalyzer - - mu sync.RWMutex - running bool - stopCh chan struct{} - interval time.Duration - initialDelay time.Duration - lastRun time.Time - - // Cache for AI analysis results (by image name) - analysisCache map[string]*AIAnalysisResponse - cacheMu sync.RWMutex - cacheExpiry time.Duration - lastCacheUpdate time.Time -} - -// Config holds discovery service configuration. -type Config struct { - DataDir string - Interval time.Duration // How often to run background discovery - CacheExpiry time.Duration // How long to cache AI analysis results -} - -// DefaultConfig returns the default discovery configuration. -func DefaultConfig() Config { - return Config{ - Interval: 10 * time.Minute, - CacheExpiry: 1 * time.Hour, - } -} - -// NewService creates a new discovery service. -func NewService(store *Store, scanner *DeepScanner, stateProvider StateProvider, cfg Config) *Service { - if cfg.Interval == 0 { - cfg.Interval = 10 * time.Minute - } - if cfg.CacheExpiry == 0 { - cfg.CacheExpiry = 1 * time.Hour - } - - return &Service{ - store: store, - scanner: scanner, - stateProvider: stateProvider, - interval: cfg.Interval, - initialDelay: 30 * time.Second, - cacheExpiry: cfg.CacheExpiry, - stopCh: make(chan struct{}), - analysisCache: make(map[string]*AIAnalysisResponse), - } -} - -// SetAIAnalyzer sets the AI analyzer for discovery. -func (s *Service) SetAIAnalyzer(analyzer AIAnalyzer) { - s.mu.Lock() - defer s.mu.Unlock() - s.aiAnalyzer = analyzer -} - -// Start begins the background discovery service. -func (s *Service) Start(ctx context.Context) { - s.mu.Lock() - if s.running { - s.mu.Unlock() - return - } - s.running = true - s.stopCh = make(chan struct{}) - s.mu.Unlock() - - log.Info(). - Dur("interval", s.interval). - Msg("Starting AI-powered infrastructure discovery service") - - go s.discoveryLoop(ctx) -} - -// Stop stops the background discovery service. -func (s *Service) Stop() { - s.mu.Lock() - defer s.mu.Unlock() - if s.running { - close(s.stopCh) - s.running = false - } -} - -// SetInterval updates the scan interval. Takes effect on next Start(). -func (s *Service) SetInterval(interval time.Duration) { - s.mu.Lock() - defer s.mu.Unlock() - s.interval = interval -} - -// 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 discovery. -func (s *Service) discoveryLoop(ctx context.Context) { - delay := s.initialDelay - if delay <= 0 { - delay = 30 * time.Second - } - - // Run initial discovery after a short delay - select { - case <-time.After(delay): - case <-s.stopCh: - return - case <-ctx.Done(): - return - } - - s.runBackgroundDiscovery(ctx) - - ticker := time.NewTicker(s.interval) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - s.runBackgroundDiscovery(ctx) - case <-s.stopCh: - log.Info().Msg("Stopping AI discovery service") - return - case <-ctx.Done(): - log.Info().Msg("AI discovery context cancelled") - return - } - } -} - -// runBackgroundDiscovery runs discovery on all resources in the background. -func (s *Service) runBackgroundDiscovery(ctx context.Context) { - defer func() { - if r := recover(); r != nil { - log.Error().Interface("panic", r).Stack().Msg("Recovered from panic in background AI discovery") - } - }() - - s.mu.Lock() - s.lastRun = time.Now() - s.mu.Unlock() - - // For background discovery, we only do shallow analysis based on metadata - // Deep scanning is triggered on-demand via DiscoverResource - if s.stateProvider == nil { - return - } - - state := s.stateProvider.GetState() - s.discoverDockerContainers(ctx, state.DockerHosts) -} - -// discoverDockerContainers runs discovery on Docker containers using metadata. -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 - } - - // Analyze using metadata (shallow discovery) - discovery := s.analyzeDockerContainer(ctx, analyzer, container, host) - if discovery != nil { - if err := s.store.Save(discovery); err != nil { - log.Warn().Err(err).Str("id", id).Msg("Failed to save discovery") - } - } - } - } -} - -// analyzeDockerContainer analyzes a Docker container using AI. -func (s *Service) analyzeDockerContainer(ctx context.Context, analyzer AIAnalyzer, c DockerContainer, host DockerHost) *ResourceDiscovery { - // Check cache first - s.cacheMu.RLock() - cached, found := s.analysisCache[c.Image] - cacheValid := time.Since(s.lastCacheUpdate) < s.cacheExpiry - s.cacheMu.RUnlock() - - var result *AIAnalysisResponse - - if found && cacheValid { - result = cached - } else { - // Build prompt for AI analysis - prompt := s.buildMetadataAnalysisPrompt(c, host) - - response, err := analyzer.AnalyzeForDiscovery(ctx, prompt) - if err != 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 - s.cacheMu.Lock() - s.analysisCache[c.Image] = result - s.lastCacheUpdate = 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, - HostID: host.AgentID, - Hostname: host.Hostname, - ServiceType: result.ServiceType, - ServiceName: result.ServiceName, - ServiceVersion: result.ServiceVersion, - Category: result.Category, - CLIAccess: cliAccess, - Facts: result.Facts, - ConfigPaths: result.ConfigPaths, - DataPaths: result.DataPaths, - Ports: ports, - Confidence: result.Confidence, - AIReasoning: result.Reasoning, - DiscoveredAt: time.Now(), - UpdatedAt: time.Now(), - } -} - -// DiscoverResource performs deep discovery on a specific resource. -func (s *Service) DiscoverResource(ctx context.Context, req DiscoveryRequest) (*ResourceDiscovery, error) { - resourceID := MakeResourceID(req.ResourceType, req.HostID, req.ResourceID) - - // Check if we have a recent discovery and force isn't set - if !req.Force { - existing, err := s.store.Get(resourceID) - if err == nil && existing != nil { - age := time.Since(existing.UpdatedAt) - if age < 5*time.Minute { - log.Debug().Str("id", resourceID).Dur("age", age).Msg("Using recent discovery") - return existing, nil - } - } - } - - s.mu.RLock() - analyzer := s.aiAnalyzer - s.mu.RUnlock() - - if analyzer == nil { - return nil, fmt.Errorf("AI analyzer not configured") - } - - // Run deep scan if scanner is available - var scanResult *ScanResult - if s.scanner != nil { - var err error - scanResult, err = s.scanner.Scan(ctx, req) - if err != nil { - log.Warn().Err(err).Str("id", resourceID).Msg("Deep scan failed, using metadata only") - } - } - - // Build analysis request - analysisReq := AIAnalysisRequest{ - ResourceType: req.ResourceType, - ResourceID: req.ResourceID, - HostID: req.HostID, - Hostname: req.Hostname, - } - - if scanResult != nil { - analysisReq.CommandOutputs = scanResult.CommandOutputs - } - - // Add metadata if available - if s.stateProvider != nil { - analysisReq.Metadata = s.getResourceMetadata(req) - } - - // Build prompt and analyze - prompt := s.buildDeepAnalysisPrompt(analysisReq) - response, err := analyzer.AnalyzeForDiscovery(ctx, prompt) - if err != nil { - return nil, fmt.Errorf("AI analysis failed: %w", err) - } - - result := s.parseAIResponse(response) - if result == nil { - // Truncate response for error message - truncated := response - if len(truncated) > 500 { - truncated = truncated[:500] + "..." - } - return nil, fmt.Errorf("failed to parse AI response: %s", truncated) - } - - // Build discovery result - discovery := &ResourceDiscovery{ - ID: resourceID, - ResourceType: req.ResourceType, - ResourceID: req.ResourceID, - HostID: req.HostID, - Hostname: req.Hostname, - ServiceType: result.ServiceType, - ServiceName: result.ServiceName, - ServiceVersion: result.ServiceVersion, - Category: result.Category, - CLIAccess: s.formatCLIAccess(req.ResourceType, req.ResourceID, result.CLIAccess), - Facts: result.Facts, - ConfigPaths: result.ConfigPaths, - DataPaths: result.DataPaths, - Ports: result.Ports, - Confidence: result.Confidence, - AIReasoning: result.Reasoning, - DiscoveredAt: time.Now(), - UpdatedAt: time.Now(), - } - - if scanResult != nil { - discovery.RawCommandOutput = scanResult.CommandOutputs - discovery.ScanDuration = scanResult.CompletedAt.Sub(scanResult.StartedAt).Milliseconds() - } - - // Preserve user notes from existing discovery - existing, _ := s.store.Get(resourceID) - if existing != nil { - discovery.UserNotes = existing.UserNotes - discovery.UserSecrets = existing.UserSecrets - if discovery.DiscoveredAt.IsZero() || existing.DiscoveredAt.Before(discovery.DiscoveredAt) { - discovery.DiscoveredAt = existing.DiscoveredAt - } - } - - // Save discovery - if err := s.store.Save(discovery); err != nil { - return nil, fmt.Errorf("failed to save discovery: %w", err) - } - - return discovery, nil -} - -// getResourceMetadata retrieves metadata for a resource from the state. -func (s *Service) getResourceMetadata(req DiscoveryRequest) map[string]any { - if s.stateProvider == nil { - return nil - } - - state := s.stateProvider.GetState() - metadata := make(map[string]any) - - switch req.ResourceType { - case ResourceTypeLXC: - for _, c := range state.Containers { - if fmt.Sprintf("%d", c.VMID) == req.ResourceID && c.Node == req.HostID { - metadata["name"] = c.Name - metadata["status"] = c.Status - metadata["vmid"] = c.VMID - break - } - } - case ResourceTypeVM: - for _, vm := range state.VMs { - if fmt.Sprintf("%d", vm.VMID) == req.ResourceID && vm.Node == req.HostID { - metadata["name"] = vm.Name - metadata["status"] = vm.Status - metadata["vmid"] = vm.VMID - break - } - } - case ResourceTypeDocker: - for _, host := range state.DockerHosts { - if host.AgentID == req.HostID || host.Hostname == req.HostID { - for _, c := range host.Containers { - if c.Name == req.ResourceID { - metadata["image"] = c.Image - metadata["status"] = c.Status - metadata["labels"] = c.Labels - break - } - } - break - } - } - } - - return metadata -} - -// 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 { - info["labels"] = c.Labels - } - - if len(c.Mounts) > 0 { - var mounts []string - for _, m := range c.Mounts { - mounts = append(mounts, m.Destination) - } - info["mounts"] = mounts - } - - infoJSON, _ := json.MarshalIndent(info, "", " ") - - 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} ", - "facts": [], - "config_paths": [], - "data_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 -Host: %s (%s)`, req.ResourceType, req.ResourceID, req.Hostname, req.HostID)) - - if len(req.Metadata) > 0 { - metaJSON, _ := json.MarshalIndent(req.Metadata, "", " ") - 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)) - } - } - - 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 ports are in use? -6. Any special hardware (GPU, TPU, etc.)? -7. Any dependencies (databases, message queues, etc.)? - -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"], - "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 -- 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 - -Respond with ONLY valid JSON.`, strings.Join(sections, "\n\n")) -} - -// parseAIResponse parses the AI's JSON response. -func (s *Service) parseAIResponse(response string) *AIAnalysisResponse { - log.Debug().Str("raw_response", response).Msg("AI discovery raw response") - response = strings.TrimSpace(response) - - // Handle markdown code blocks - if strings.HasPrefix(response, "```") { - lines := strings.Split(response, "\n") - var jsonLines []string - inBlock := false - for _, line := range lines { - if strings.HasPrefix(line, "```") { - inBlock = !inBlock - continue - } - if inBlock { - jsonLines = append(jsonLines, line) - } - } - response = strings.Join(jsonLines, "\n") - } - - // Find JSON object - start := strings.Index(response, "{") - end := strings.LastIndex(response, "}") - if start >= 0 && end > start { - response = response[start : end+1] - } - - var result AIAnalysisResponse - if err := json.Unmarshal([]byte(response), &result); err != nil { - log.Debug().Err(err).Str("response", response).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 -} - -// GetDiscovery retrieves a discovery by ID. -func (s *Service) GetDiscovery(id string) (*ResourceDiscovery, error) { - return s.store.Get(id) -} - -// GetDiscoveryByResource retrieves a discovery by resource type and ID. -func (s *Service) GetDiscoveryByResource(resourceType ResourceType, hostID, resourceID string) (*ResourceDiscovery, error) { - return s.store.GetByResource(resourceType, hostID, resourceID) -} - -// ListDiscoveries returns all discoveries. -func (s *Service) ListDiscoveries() ([]*ResourceDiscovery, error) { - return s.store.List() -} - -// ListDiscoveriesByType returns discoveries for a specific resource type. -func (s *Service) ListDiscoveriesByType(resourceType ResourceType) ([]*ResourceDiscovery, error) { - return s.store.ListByType(resourceType) -} - -// ListDiscoveriesByHost returns discoveries for a specific host. -func (s *Service) ListDiscoveriesByHost(hostID string) ([]*ResourceDiscovery, error) { - return s.store.ListByHost(hostID) -} - -// 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. -func (s *Service) GetStatus() map[string]any { - s.mu.RLock() - defer s.mu.RUnlock() - - s.cacheMu.RLock() - cacheSize := len(s.analysisCache) - s.cacheMu.RUnlock() - - return map[string]any{ - "running": s.running, - "last_run": s.lastRun, - "interval": s.interval.String(), - "cache_size": cacheSize, - "ai_analyzer_set": s.aiAnalyzer != nil, - "scanner_set": s.scanner != nil, - "store_set": s.store != nil, - } -} - -// ClearCache clears the AI analysis cache. -func (s *Service) ClearCache() { - s.cacheMu.Lock() - defer s.cacheMu.Unlock() - s.analysisCache = make(map[string]*AIAnalysisResponse) - s.lastCacheUpdate = time.Time{} -} diff --git a/internal/aidiscovery/service_test.go b/internal/aidiscovery/service_test.go deleted file mode 100644 index 6c49c70ba..000000000 --- a/internal/aidiscovery/service_test.go +++ /dev/null @@ -1,658 +0,0 @@ -package aidiscovery - -import ( - "context" - "os" - "path/filepath" - "strings" - "sync" - "testing" - "time" -) - -type stubAnalyzer struct { - mu sync.Mutex - calls int - response string -} - -func (s *stubAnalyzer) AnalyzeForDiscovery(ctx context.Context, prompt string) (string, error) { - s.mu.Lock() - s.calls++ - s.mu.Unlock() - return s.response, nil -} - -type errorAnalyzer struct{} - -func (errorAnalyzer) AnalyzeForDiscovery(ctx context.Context, prompt string) (string, error) { - return "", context.Canceled -} - -type stubStateProvider struct { - state StateSnapshot -} - -func (s stubStateProvider) GetState() StateSnapshot { - return s.state -} - -type panicStateProvider struct{} - -func (panicStateProvider) GetState() StateSnapshot { - panic("boom") -} - -func TestService_parseAIResponse_Markdown(t *testing.T) { - service := &Service{} - response := "```json\n{\n \"service_type\": \"nginx\",\n \"service_name\": \"Nginx\",\n \"service_version\": \"1.2\",\n \"category\": \"web_server\",\n \"cli_access\": \"docker exec {container} bash\",\n \"facts\": [{\"category\": \"version\", \"key\": \"nginx\", \"value\": \"1.2\", \"source\": \"cmd\", \"confidence\": 0.9}],\n \"config_paths\": [\"/etc/nginx/nginx.conf\"],\n \"data_paths\": [\"/var/www\"],\n \"ports\": [{\"port\": 80, \"protocol\": \"tcp\", \"process\": \"nginx\", \"address\": \"0.0.0.0\"}],\n \"confidence\": 0.9,\n \"reasoning\": \"image name\"\n}\n```" - - parsed := service.parseAIResponse(response) - if parsed == nil { - t.Fatalf("expected parsed response") - } - if parsed.ServiceType != "nginx" || parsed.ServiceName != "Nginx" { - t.Fatalf("unexpected parsed result: %#v", parsed) - } - if len(parsed.Facts) != 1 || parsed.Facts[0].DiscoveredAt.IsZero() { - t.Fatalf("expected fact timestamp set: %#v", parsed.Facts) - } - - if service.parseAIResponse("not json") != nil { - t.Fatalf("expected nil for invalid json") - } -} - -func TestService_analyzeDockerContainer_CacheAndPorts(t *testing.T) { - store, err := NewStore(t.TempDir()) - if err != nil { - t.Fatalf("NewStore error: %v", err) - } - store.crypto = nil - service := NewService(store, nil, nil, Config{CacheExpiry: time.Hour}) - - analyzer := &stubAnalyzer{ - response: `{"service_type":"nginx","service_name":"Nginx","service_version":"1.2","category":"web_server","cli_access":"docker exec {container} nginx -v","facts":[],"config_paths":[],"data_paths":[],"ports":[],"confidence":0.9,"reasoning":"image"}`, - } - - container := DockerContainer{ - Name: "web", - Image: "nginx:latest", - Status: "running", - Ports: []DockerPort{ - {PublicPort: 8080, PrivatePort: 80, Protocol: "tcp"}, - }, - } - host := DockerHost{ - AgentID: "host1", - Hostname: "host1", - } - - first := service.analyzeDockerContainer(context.Background(), analyzer, container, host) - if first == nil { - t.Fatalf("expected discovery") - } - if !strings.Contains(first.CLIAccess, "web") { - t.Fatalf("expected cli access to include container name, got %s", first.CLIAccess) - } - if len(first.Ports) != 1 || first.Ports[0].Port != 80 || first.Ports[0].Address != ":8080" { - t.Fatalf("unexpected ports: %#v", first.Ports) - } - - second := service.analyzeDockerContainer(context.Background(), analyzer, container, host) - if second == nil { - t.Fatalf("expected cached discovery") - } - - analyzer.mu.Lock() - calls := analyzer.calls - analyzer.mu.Unlock() - if calls != 1 { - t.Fatalf("expected analyzer called once, got %d", calls) - } - - lowAnalyzer := &stubAnalyzer{ - response: `{"service_type":"unknown","service_name":"","service_version":"","category":"unknown","cli_access":"","facts":[],"config_paths":[],"data_paths":[],"ports":[],"confidence":0.4,"reasoning":""}`, - } - lowContainer := DockerContainer{Name: "mystery", Image: "unknown:latest"} - if got := service.analyzeDockerContainer(context.Background(), lowAnalyzer, lowContainer, host); got != nil { - t.Fatalf("expected low confidence discovery to be skipped") - } -} - -func TestService_DiscoverResource_RecentAndNoAnalyzer(t *testing.T) { - store, err := NewStore(t.TempDir()) - if err != nil { - t.Fatalf("NewStore error: %v", err) - } - store.crypto = nil - service := NewService(store, nil, nil, DefaultConfig()) - - req := DiscoveryRequest{ - ResourceType: ResourceTypeDocker, - ResourceID: "nginx", - HostID: "host1", - Hostname: "host1", - } - discovery := &ResourceDiscovery{ - ID: MakeResourceID(req.ResourceType, req.HostID, req.ResourceID), - ResourceType: req.ResourceType, - ResourceID: req.ResourceID, - HostID: req.HostID, - Hostname: req.Hostname, - ServiceName: "Existing", - } - if err := store.Save(discovery); err != nil { - t.Fatalf("Save error: %v", err) - } - - found, err := service.DiscoverResource(context.Background(), req) - if err != nil { - t.Fatalf("DiscoverResource error: %v", err) - } - if found == nil || found.ServiceName != "Existing" { - t.Fatalf("unexpected discovery: %#v", found) - } - - _, err = service.DiscoverResource(context.Background(), DiscoveryRequest{ - ResourceType: ResourceTypeVM, - ResourceID: "101", - HostID: "node1", - Hostname: "node1", - Force: true, - }) - if err == nil || !strings.Contains(err.Error(), "AI analyzer") { - t.Fatalf("expected analyzer error, got %v", err) - } - - service.SetAIAnalyzer(errorAnalyzer{}) - _, err = service.DiscoverResource(context.Background(), DiscoveryRequest{ - ResourceType: ResourceTypeVM, - ResourceID: "102", - HostID: "node1", - Hostname: "node1", - Force: true, - }) - if err == nil || !strings.Contains(err.Error(), "AI analysis failed") { - t.Fatalf("expected analysis error, got %v", err) - } - - service.SetAIAnalyzer(&stubAnalyzer{response: "not json"}) - _, err = service.DiscoverResource(context.Background(), DiscoveryRequest{ - ResourceType: ResourceTypeVM, - ResourceID: "103", - HostID: "node1", - Hostname: "node1", - Force: true, - }) - if err == nil || !strings.Contains(err.Error(), "failed to parse") { - t.Fatalf("expected parse error, got %v", err) - } -} - -func TestService_getResourceMetadata(t *testing.T) { - state := StateSnapshot{ - VMs: []VM{ - {VMID: 101, Name: "vm1", Node: "node1", Status: "running"}, - }, - Containers: []Container{ - {VMID: 201, Name: "lxc1", Node: "node2", Status: "stopped"}, - }, - DockerHosts: []DockerHost{ - { - AgentID: "agent1", - Hostname: "dock1", - Containers: []DockerContainer{ - {Name: "redis", Image: "redis:latest", Status: "running", Labels: map[string]string{"tier": "cache"}}, - }, - }, - }, - } - - service := NewService(nil, nil, stubStateProvider{state: state}, DefaultConfig()) - - vmMeta := service.getResourceMetadata(DiscoveryRequest{ - ResourceType: ResourceTypeVM, - ResourceID: "101", - HostID: "node1", - }) - if vmMeta["name"] != "vm1" || vmMeta["vmid"] != 101 { - t.Fatalf("unexpected vm metadata: %#v", vmMeta) - } - - lxcMeta := service.getResourceMetadata(DiscoveryRequest{ - ResourceType: ResourceTypeLXC, - ResourceID: "201", - HostID: "node2", - }) - if lxcMeta["name"] != "lxc1" || lxcMeta["status"] != "stopped" { - t.Fatalf("unexpected lxc metadata: %#v", lxcMeta) - } - - dockerMeta := service.getResourceMetadata(DiscoveryRequest{ - ResourceType: ResourceTypeDocker, - ResourceID: "redis", - HostID: "agent1", - }) - if dockerMeta["image"] != "redis:latest" || dockerMeta["status"] != "running" { - t.Fatalf("unexpected docker metadata: %#v", dockerMeta) - } - - dockerByHost := service.getResourceMetadata(DiscoveryRequest{ - ResourceType: ResourceTypeDocker, - ResourceID: "redis", - HostID: "dock1", - }) - if dockerByHost["image"] != "redis:latest" { - t.Fatalf("unexpected docker hostname metadata: %#v", dockerByHost) - } -} - -func TestService_formatCLIAccessAndStatus(t *testing.T) { - service := NewService(nil, nil, nil, DefaultConfig()) - formatted := service.formatCLIAccess(ResourceTypeDocker, "redis", "") - if !strings.Contains(formatted, "redis") || !strings.Contains(formatted, "...") { - t.Fatalf("unexpected cli access: %s", formatted) - } - - service.analysisCache = map[string]*AIAnalysisResponse{"nginx:latest": {ServiceType: "nginx"}} - service.running = true - status := service.GetStatus() - if status["running"] != true || status["cache_size"] != 1 { - t.Fatalf("unexpected status: %#v", status) - } - - service.ClearCache() - if len(service.analysisCache) != 0 { - t.Fatalf("expected cache cleared") - } -} - -func TestService_DefaultsAndSetAnalyzer(t *testing.T) { - service := NewService(nil, nil, nil, Config{}) - if service.interval == 0 || service.cacheExpiry == 0 { - t.Fatalf("expected defaults for interval and cache expiry") - } - - analyzer := &stubAnalyzer{response: `{}`} - service.SetAIAnalyzer(analyzer) - if service.aiAnalyzer == nil { - t.Fatalf("expected analyzer set") - } - if service.GetProgress("missing") != nil { - t.Fatalf("expected nil progress without scanner") - } - if service.getResourceMetadata(DiscoveryRequest{}) != nil { - t.Fatalf("expected nil metadata without state provider") - } -} - -func TestService_RunBackgroundDiscoveryAndWrappers(t *testing.T) { - store, err := NewStore(t.TempDir()) - if err != nil { - t.Fatalf("NewStore error: %v", err) - } - store.crypto = nil - state := StateSnapshot{ - DockerHosts: []DockerHost{ - { - AgentID: "host1", - Hostname: "host1", - Containers: []DockerContainer{ - {Name: "web", Image: "nginx:latest", Status: "running"}, - }, - }, - }, - } - service := NewService(store, nil, stubStateProvider{state: state}, DefaultConfig()) - service.SetAIAnalyzer(&stubAnalyzer{ - response: `{"service_type":"nginx","service_name":"Nginx","service_version":"1.2","category":"web_server","cli_access":"docker exec {container} nginx -v","facts":[],"config_paths":[],"data_paths":[],"ports":[],"confidence":0.9,"reasoning":"image"}`, - }) - - service.runBackgroundDiscovery(context.Background()) - id := MakeResourceID(ResourceTypeDocker, "host1", "web") - - if got, err := service.GetDiscovery(id); err != nil || got == nil { - t.Fatalf("GetDiscovery error: %v", err) - } - if got, err := service.GetDiscoveryByResource(ResourceTypeDocker, "host1", "web"); err != nil || got == nil { - t.Fatalf("GetDiscoveryByResource error: %v", err) - } - - if list, err := service.ListDiscoveries(); err != nil || len(list) != 1 { - t.Fatalf("ListDiscoveries unexpected: %v len=%d", err, len(list)) - } - if list, err := service.ListDiscoveriesByType(ResourceTypeDocker); err != nil || len(list) != 1 { - t.Fatalf("ListDiscoveriesByType unexpected: %v len=%d", err, len(list)) - } - if list, err := service.ListDiscoveriesByHost("host1"); err != nil || len(list) != 1 { - t.Fatalf("ListDiscoveriesByHost unexpected: %v len=%d", err, len(list)) - } - - if err := service.UpdateNotes(id, "note", map[string]string{"k": "v"}); err != nil { - t.Fatalf("UpdateNotes error: %v", err) - } - updated, err := service.GetDiscovery(id) - if err != nil || updated.UserNotes != "note" { - t.Fatalf("expected updated notes: %#v err=%v", updated, err) - } - - scanner := NewDeepScanner(&stubExecutor{}) - scanner.progress[id] = &DiscoveryProgress{ResourceID: id} - service.scanner = scanner - if service.GetProgress(id) == nil { - t.Fatalf("expected progress") - } - - if err := service.DeleteDiscovery(id); err != nil { - t.Fatalf("DeleteDiscovery error: %v", err) - } - - service.stateProvider = nil - service.runBackgroundDiscovery(context.Background()) -} - -func TestService_PromptsAndDiscoveryLoop(t *testing.T) { - service := NewService(nil, nil, nil, DefaultConfig()) - - container := DockerContainer{ - Name: "web", - Image: "nginx:latest", - Status: "running", - Ports: []DockerPort{ - {PublicPort: 8080, PrivatePort: 80, Protocol: "tcp"}, - }, - Labels: map[string]string{"app": "nginx"}, - Mounts: []DockerMount{{Destination: "/etc/nginx"}}, - } - host := DockerHost{Hostname: "host1"} - prompt := service.buildMetadataAnalysisPrompt(container, host) - if !strings.Contains(prompt, "\"ports\"") || !strings.Contains(prompt, "\"labels\"") || !strings.Contains(prompt, "\"mounts\"") { - t.Fatalf("unexpected metadata prompt: %s", prompt) - } - - longOutput := strings.Repeat("a", 2100) - deepPrompt := service.buildDeepAnalysisPrompt(AIAnalysisRequest{ - ResourceType: ResourceTypeDocker, - ResourceID: "web", - HostID: "host1", - Hostname: "host1", - Metadata: map[string]any{"image": "nginx"}, - CommandOutputs: map[string]string{ - "ps": longOutput, - }, - }) - if !strings.Contains(deepPrompt, "(truncated)") || !strings.Contains(deepPrompt, "Metadata:") { - t.Fatalf("unexpected deep prompt") - } - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - service.initialDelay = time.Millisecond - service.Start(ctx) - service.Start(ctx) - service.Stop() - - service.stopCh = make(chan struct{}) - close(service.stopCh) - service.discoveryLoop(context.Background()) - - service.initialDelay = 0 - service.stopCh = make(chan struct{}) - close(service.stopCh) - service.discoveryLoop(context.Background()) -} - -func TestService_DiscoveryLoop_StopAndCancel(t *testing.T) { - state := StateSnapshot{ - DockerHosts: []DockerHost{ - { - AgentID: "host1", - Hostname: "host1", - Containers: []DockerContainer{ - {Name: "web", Image: "nginx:latest", Status: "running"}, - }, - }, - }, - } - - runLoop := func(stopWithCancel bool) { - store, err := NewStore(t.TempDir()) - if err != nil { - t.Fatalf("NewStore error: %v", err) - } - store.crypto = nil - - service := NewService(store, nil, stubStateProvider{state: state}, DefaultConfig()) - analyzer := &stubAnalyzer{ - response: `{"service_type":"nginx","service_name":"Nginx","service_version":"1.2","category":"web_server","cli_access":"docker exec {container} nginx -v","facts":[],"config_paths":[],"data_paths":[],"ports":[],"confidence":0.9,"reasoning":"image"}`, - } - service.SetAIAnalyzer(analyzer) - service.initialDelay = time.Millisecond - service.interval = time.Millisecond - service.cacheExpiry = time.Nanosecond - - done := make(chan struct{}) - ctx, cancel := context.WithCancel(context.Background()) - go func() { - service.discoveryLoop(ctx) - close(done) - }() - - time.Sleep(5 * time.Millisecond) - if stopWithCancel { - cancel() - } else { - close(service.stopCh) - } - - select { - case <-done: - case <-time.After(50 * time.Millisecond): - t.Fatalf("discoveryLoop did not stop") - } - - analyzer.mu.Lock() - calls := analyzer.calls - analyzer.mu.Unlock() - if calls < 2 { - t.Fatalf("expected multiple discoveries, got %d", calls) - } - } - - runLoop(false) - runLoop(true) -} - -func TestService_DiscoverDockerContainersSkips(t *testing.T) { - store, err := NewStore(t.TempDir()) - if err != nil { - t.Fatalf("NewStore error: %v", err) - } - store.crypto = nil - - service := NewService(store, nil, nil, DefaultConfig()) - service.discoverDockerContainers(context.Background(), []DockerHost{{AgentID: "host1"}}) - - service.SetAIAnalyzer(&stubAnalyzer{ - response: `{"service_type":"nginx","service_name":"Nginx","service_version":"1.2","category":"web_server","cli_access":"docker exec {container} nginx -v","facts":[],"config_paths":[],"data_paths":[],"ports":[],"confidence":0.9,"reasoning":"image"}`, - }) - - id := MakeResourceID(ResourceTypeDocker, "host1", "web") - if err := store.Save(&ResourceDiscovery{ID: id, ResourceType: ResourceTypeDocker}); err != nil { - t.Fatalf("Save error: %v", err) - } - service.cacheExpiry = time.Hour - service.discoverDockerContainers(context.Background(), []DockerHost{ - {AgentID: "host1", Containers: []DockerContainer{{Name: "web", Image: "nginx:latest"}}}, - }) - - badAnalyzer := &stubAnalyzer{response: "not json"} - if got := service.analyzeDockerContainer(context.Background(), badAnalyzer, DockerContainer{Name: "bad", Image: "bad"}, DockerHost{AgentID: "host1"}); got != nil { - t.Fatalf("expected nil for bad analysis") - } - - canceled, cancel := context.WithCancel(context.Background()) - cancel() - analyzer := &stubAnalyzer{response: `{"service_type":"nginx","service_name":"Nginx","service_version":"1.2","category":"web_server","cli_access":"docker exec {container} nginx -v","facts":[],"config_paths":[],"data_paths":[],"ports":[],"confidence":0.9,"reasoning":"image"}`} - service.SetAIAnalyzer(analyzer) - service.discoverDockerContainers(canceled, []DockerHost{ - {AgentID: "host1", Containers: []DockerContainer{{Name: "web2", Image: "nginx:latest"}}}, - }) - analyzer.mu.Lock() - calls := analyzer.calls - analyzer.mu.Unlock() - if calls != 0 { - t.Fatalf("expected analyzer not called on canceled context") - } - - errAnalyzer := errorAnalyzer{} - if got := service.analyzeDockerContainer(context.Background(), errAnalyzer, DockerContainer{Name: "err", Image: "err"}, DockerHost{AgentID: "host1"}); got != nil { - t.Fatalf("expected nil when analyzer returns error") - } - - storePath := filepath.Join(t.TempDir(), "file") - if err := os.WriteFile(storePath, []byte("x"), 0600); err != nil { - t.Fatalf("WriteFile error: %v", err) - } - service.store.dataDir = storePath - service.discoverDockerContainers(context.Background(), []DockerHost{ - {AgentID: "host1", Containers: []DockerContainer{{Name: "web3", Image: "nginx:latest"}}}, - }) -} - -func TestService_RunBackgroundDiscoveryRecover(t *testing.T) { - service := NewService(nil, nil, panicStateProvider{}, DefaultConfig()) - service.runBackgroundDiscovery(context.Background()) -} - -func TestService_DiscoverResource_SaveError(t *testing.T) { - store, err := NewStore(t.TempDir()) - if err != nil { - t.Fatalf("NewStore error: %v", err) - } - store.crypto = nil - - badPath := filepath.Join(t.TempDir(), "file") - if err := os.WriteFile(badPath, []byte("x"), 0600); err != nil { - t.Fatalf("WriteFile error: %v", err) - } - store.dataDir = badPath - - service := NewService(store, nil, nil, DefaultConfig()) - service.SetAIAnalyzer(&stubAnalyzer{ - response: `{"service_type":"nginx","service_name":"Nginx","service_version":"1.2","category":"web_server","cli_access":"docker exec {container} nginx -v","facts":[],"config_paths":[],"data_paths":[],"ports":[],"confidence":0.9,"reasoning":"image"}`, - }) - - _, err = service.DiscoverResource(context.Background(), DiscoveryRequest{ - ResourceType: ResourceTypeDocker, - ResourceID: "web", - HostID: "host1", - Hostname: "host1", - Force: true, - }) - if err == nil || !strings.Contains(err.Error(), "failed to save discovery") { - t.Fatalf("expected save error, got %v", err) - } -} - -func TestService_DiscoverResource_ScanError(t *testing.T) { - store, err := NewStore(t.TempDir()) - if err != nil { - t.Fatalf("NewStore error: %v", err) - } - store.crypto = nil - - scanner := NewDeepScanner(nil) - service := NewService(store, scanner, nil, DefaultConfig()) - service.SetAIAnalyzer(&stubAnalyzer{ - response: `{"service_type":"nginx","service_name":"Nginx","service_version":"1.2","category":"web_server","cli_access":"docker exec {container} nginx -v","facts":[],"config_paths":[],"data_paths":[],"ports":[],"confidence":0.9,"reasoning":"image"}`, - }) - - _, err = service.DiscoverResource(context.Background(), DiscoveryRequest{ - ResourceType: ResourceTypeDocker, - ResourceID: "web", - HostID: "host1", - Hostname: "host1", - Force: true, - }) - if err != nil { - t.Fatalf("expected scan error to be tolerated, got %v", err) - } -} - -func TestService_DiscoveryLoop_ContextDoneAtStart(t *testing.T) { - service := NewService(nil, nil, nil, DefaultConfig()) - service.initialDelay = time.Hour - service.stopCh = make(chan struct{}) - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - service.discoveryLoop(ctx) -} - -func TestService_DiscoverResource_WithScanResult(t *testing.T) { - store, err := NewStore(t.TempDir()) - if err != nil { - t.Fatalf("NewStore error: %v", err) - } - store.crypto = nil - - exec := &stubExecutor{ - agents: []ConnectedAgent{{AgentID: "host1", Hostname: "host1"}}, - } - scanner := NewDeepScanner(exec) - scanner.maxParallel = 1 - - state := StateSnapshot{ - DockerHosts: []DockerHost{ - { - AgentID: "host1", - Hostname: "host1", - Containers: []DockerContainer{ - {Name: "web", Image: "nginx:latest", Status: "running"}, - }, - }, - }, - } - - service := NewService(store, scanner, stubStateProvider{state: state}, DefaultConfig()) - service.SetAIAnalyzer(&stubAnalyzer{ - response: `{"service_type":"nginx","service_name":"Nginx","service_version":"1.2","category":"web_server","cli_access":"docker exec {container} nginx -v","facts":[],"config_paths":[],"data_paths":[],"ports":[{"port":80,"protocol":"tcp","process":"nginx","address":"0.0.0.0"}],"confidence":0.9,"reasoning":"image"}`, - }) - - existing := &ResourceDiscovery{ - ID: MakeResourceID(ResourceTypeDocker, "host1", "web"), - ResourceType: ResourceTypeDocker, - ResourceID: "web", - HostID: "host1", - Hostname: "host1", - UserNotes: "keep", - UserSecrets: map[string]string{"token": "secret"}, - DiscoveredAt: time.Now().Add(-2 * time.Hour), - } - if err := store.Save(existing); err != nil { - t.Fatalf("Save error: %v", err) - } - - found, err := service.DiscoverResource(context.Background(), DiscoveryRequest{ - ResourceType: ResourceTypeDocker, - ResourceID: "web", - HostID: "host1", - Hostname: "host1", - Force: true, - }) - if err != nil { - t.Fatalf("DiscoverResource error: %v", err) - } - if found.UserNotes != "keep" || found.UserSecrets["token"] != "secret" { - t.Fatalf("expected user fields preserved: %#v", found) - } - if len(found.RawCommandOutput) == 0 { - t.Fatalf("expected raw command output") - } - if found.DiscoveredAt.After(existing.DiscoveredAt) { - t.Fatalf("expected older discovered_at preserved") - } -} diff --git a/internal/aidiscovery/store.go b/internal/aidiscovery/store.go deleted file mode 100644 index a38411d5a..000000000 --- a/internal/aidiscovery/store.go +++ /dev/null @@ -1,347 +0,0 @@ -package aidiscovery - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/rcourtman/pulse-go-rewrite/internal/crypto" - "github.com/rs/zerolog/log" -) - -// CryptoManager interface for encryption/decryption. -type CryptoManager interface { - Encrypt(plaintext []byte) ([]byte, error) - Decrypt(ciphertext []byte) ([]byte, error) -} - -// Store provides encrypted per-resource storage for discovery data. -type Store struct { - mu sync.RWMutex - dataDir string - crypto CryptoManager - cache map[string]*ResourceDiscovery // In-memory cache - cacheTime map[string]time.Time // Cache timestamps - cacheTTL time.Duration -} - -// For testing - allows injecting a mock crypto manager -var newCryptoManagerAt = crypto.NewCryptoManagerAt - -// For testing - allows injecting a mock marshaler. -var marshalDiscovery = json.Marshal - -// NewStore creates a new discovery store with automatic encryption. -func NewStore(dataDir string) (*Store, error) { - discoveryDir := filepath.Join(dataDir, "discovery") - if err := os.MkdirAll(discoveryDir, 0700); err != nil { - return nil, fmt.Errorf("failed to create discovery directory: %w", err) - } - - // Initialize crypto manager for encryption (uses same key as other Pulse secrets) - cryptoMgr, err := newCryptoManagerAt(dataDir) - if err != nil { - log.Warn().Err(err).Msg("Failed to initialize crypto for discovery store, data will be unencrypted") - } - - return &Store{ - dataDir: discoveryDir, - crypto: cryptoMgr, - cache: make(map[string]*ResourceDiscovery), - cacheTime: make(map[string]time.Time), - cacheTTL: 5 * time.Minute, - }, nil -} - -// getFilePath returns the file path for a resource ID. -func (s *Store) getFilePath(id string) string { - // Sanitize ID for filename: replace : with _ - safeID := strings.ReplaceAll(id, ":", "_") - safeID = strings.ReplaceAll(safeID, "/", "_") - return filepath.Join(s.dataDir, safeID+".enc") -} - -// Save persists a discovery to encrypted storage. -func (s *Store) Save(d *ResourceDiscovery) error { - s.mu.Lock() - defer s.mu.Unlock() - - if d.ID == "" { - return fmt.Errorf("discovery ID is required") - } - - // Update timestamp - d.UpdatedAt = time.Now() - if d.DiscoveredAt.IsZero() { - d.DiscoveredAt = d.UpdatedAt - } - - data, err := marshalDiscovery(d) - if err != nil { - return fmt.Errorf("failed to marshal discovery: %w", err) - } - - // Encrypt if crypto is available - if s.crypto != nil { - encrypted, err := s.crypto.Encrypt(data) - if err != nil { - return fmt.Errorf("failed to encrypt discovery: %w", err) - } - data = encrypted - } - - // Write atomically using tmp file + rename - filePath := s.getFilePath(d.ID) - tmpPath := filePath + ".tmp" - - if err := os.WriteFile(tmpPath, data, 0600); err != nil { - return fmt.Errorf("failed to write discovery file: %w", err) - } - - if err := os.Rename(tmpPath, filePath); err != nil { - _ = os.Remove(tmpPath) - return fmt.Errorf("failed to finalize discovery file: %w", err) - } - - // Update cache - s.cache[d.ID] = d - s.cacheTime[d.ID] = time.Now() - - log.Debug().Str("id", d.ID).Str("service", d.ServiceType).Msg("Discovery saved") - return nil -} - -// Get retrieves a discovery from storage. -func (s *Store) Get(id string) (*ResourceDiscovery, error) { - s.mu.RLock() - // Check cache first - if cached, ok := s.cache[id]; ok { - if cacheTime, hasTime := s.cacheTime[id]; hasTime { - if time.Since(cacheTime) < s.cacheTTL { - s.mu.RUnlock() - return cached, nil - } - } - } - s.mu.RUnlock() - - s.mu.Lock() - defer s.mu.Unlock() - - filePath := s.getFilePath(id) - data, err := os.ReadFile(filePath) - if err != nil { - if os.IsNotExist(err) { - return nil, nil // Not found is not an error - } - return nil, fmt.Errorf("failed to read discovery file: %w", err) - } - - // Decrypt if crypto is available - if s.crypto != nil { - decrypted, err := s.crypto.Decrypt(data) - if err != nil { - return nil, fmt.Errorf("failed to decrypt discovery: %w", err) - } - data = decrypted - } - - var discovery ResourceDiscovery - if err := json.Unmarshal(data, &discovery); err != nil { - return nil, fmt.Errorf("failed to unmarshal discovery: %w", err) - } - - // Update cache - s.cache[id] = &discovery - s.cacheTime[id] = time.Now() - - return &discovery, nil -} - -// GetByResource retrieves a discovery by resource type and ID. -func (s *Store) GetByResource(resourceType ResourceType, hostID, resourceID string) (*ResourceDiscovery, error) { - id := MakeResourceID(resourceType, hostID, resourceID) - return s.Get(id) -} - -// Delete removes a discovery from storage. -func (s *Store) Delete(id string) error { - s.mu.Lock() - defer s.mu.Unlock() - - filePath := s.getFilePath(id) - if err := os.Remove(filePath); err != nil { - if os.IsNotExist(err) { - return nil // Already deleted - } - return fmt.Errorf("failed to delete discovery file: %w", err) - } - - // Remove from cache - delete(s.cache, id) - delete(s.cacheTime, id) - - log.Debug().Str("id", id).Msg("Discovery deleted") - return nil -} - -// List returns all discoveries. -func (s *Store) List() ([]*ResourceDiscovery, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - entries, err := os.ReadDir(s.dataDir) - if err != nil { - if os.IsNotExist(err) { - return []*ResourceDiscovery{}, nil - } - return nil, fmt.Errorf("failed to list discovery directory: %w", err) - } - - var discoveries []*ResourceDiscovery - for _, entry := range entries { - // Skip tmp files first to avoid reading partial writes. - if strings.HasSuffix(entry.Name(), ".tmp") { - continue - } - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".enc") { - continue - } - - data, err := os.ReadFile(filepath.Join(s.dataDir, entry.Name())) - if err != nil { - log.Warn().Err(err).Str("file", entry.Name()).Msg("Failed to read discovery file") - continue - } - - // Decrypt if crypto is available - if s.crypto != nil { - decrypted, err := s.crypto.Decrypt(data) - if err != nil { - log.Warn().Err(err).Str("file", entry.Name()).Msg("Failed to decrypt discovery") - continue - } - data = decrypted - } - - var discovery ResourceDiscovery - if err := json.Unmarshal(data, &discovery); err != nil { - log.Warn().Err(err).Str("file", entry.Name()).Msg("Failed to unmarshal discovery") - continue - } - - discoveries = append(discoveries, &discovery) - } - - return discoveries, nil -} - -// ListByType returns discoveries for a specific resource type. -func (s *Store) ListByType(resourceType ResourceType) ([]*ResourceDiscovery, error) { - all, err := s.List() - if err != nil { - return nil, err - } - - var filtered []*ResourceDiscovery - for _, d := range all { - if d.ResourceType == resourceType { - filtered = append(filtered, d) - } - } - return filtered, nil -} - -// ListByHost returns discoveries for a specific host. -func (s *Store) ListByHost(hostID string) ([]*ResourceDiscovery, error) { - all, err := s.List() - if err != nil { - return nil, err - } - - var filtered []*ResourceDiscovery - for _, d := range all { - if d.HostID == hostID { - filtered = append(filtered, d) - } - } - return filtered, nil -} - -// UpdateNotes updates just the user notes and secrets for a discovery. -func (s *Store) UpdateNotes(id string, notes string, secrets map[string]string) error { - discovery, err := s.Get(id) - if err != nil { - return err - } - if discovery == nil { - return fmt.Errorf("discovery not found: %s", id) - } - - discovery.UserNotes = notes - if secrets != nil { - discovery.UserSecrets = secrets - } - - return s.Save(discovery) -} - -// GetMultiple retrieves multiple discoveries by ID. -func (s *Store) GetMultiple(ids []string) ([]*ResourceDiscovery, error) { - var discoveries []*ResourceDiscovery - for _, id := range ids { - d, err := s.Get(id) - if err != nil { - log.Warn().Err(err).Str("id", id).Msg("Failed to get discovery") - continue - } - if d != nil { - discoveries = append(discoveries, d) - } - } - return discoveries, nil -} - -// ClearCache clears the in-memory cache. -func (s *Store) ClearCache() { - s.mu.Lock() - defer s.mu.Unlock() - s.cache = make(map[string]*ResourceDiscovery) - s.cacheTime = make(map[string]time.Time) -} - -// Exists checks if a discovery exists for the given ID. -func (s *Store) Exists(id string) bool { - s.mu.RLock() - if _, ok := s.cache[id]; ok { - s.mu.RUnlock() - return true - } - s.mu.RUnlock() - - filePath := s.getFilePath(id) - _, err := os.Stat(filePath) - return err == nil -} - -// GetAge returns how old the discovery is, or -1 if not found. -func (s *Store) GetAge(id string) time.Duration { - d, err := s.Get(id) - if err != nil || d == nil { - return -1 - } - return time.Since(d.UpdatedAt) -} - -// NeedsRefresh checks if a discovery needs to be refreshed. -func (s *Store) NeedsRefresh(id string, maxAge time.Duration) bool { - age := s.GetAge(id) - if age < 0 { - return true // Not found, needs discovery - } - return age > maxAge -} diff --git a/internal/aidiscovery/store_test.go b/internal/aidiscovery/store_test.go deleted file mode 100644 index ec1e32121..000000000 --- a/internal/aidiscovery/store_test.go +++ /dev/null @@ -1,469 +0,0 @@ -package aidiscovery - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/rcourtman/pulse-go-rewrite/internal/crypto" -) - -type fakeCrypto struct{} - -func (fakeCrypto) Encrypt(plaintext []byte) ([]byte, error) { - out := make([]byte, len(plaintext)) - for i := range plaintext { - out[i] = plaintext[len(plaintext)-1-i] - } - return out, nil -} - -func (fakeCrypto) Decrypt(ciphertext []byte) ([]byte, error) { - return fakeCrypto{}.Encrypt(ciphertext) -} - -type errorCrypto struct{} - -func (errorCrypto) Encrypt(plaintext []byte) ([]byte, error) { - return nil, os.ErrInvalid -} - -func (errorCrypto) Decrypt(ciphertext []byte) ([]byte, error) { - return nil, os.ErrInvalid -} - -func TestStore_SaveGetListAndNotes(t *testing.T) { - store, err := NewStore(t.TempDir()) - if err != nil { - t.Fatalf("NewStore error: %v", err) - } - store.crypto = nil - - d1 := &ResourceDiscovery{ - ID: MakeResourceID(ResourceTypeDocker, "host1", "nginx"), - ResourceType: ResourceTypeDocker, - ResourceID: "nginx", - HostID: "host1", - ServiceName: "Nginx", - } - if err := store.Save(d1); err != nil { - t.Fatalf("Save error: %v", err) - } - - got, err := store.Get(d1.ID) - if err != nil { - t.Fatalf("Get error: %v", err) - } - if got == nil || got.ServiceName != "Nginx" { - t.Fatalf("unexpected discovery: %#v", got) - } - if !store.Exists(d1.ID) { - t.Fatalf("expected discovery to exist") - } - - if err := store.UpdateNotes(d1.ID, "notes", map[string]string{"token": "abc"}); err != nil { - t.Fatalf("UpdateNotes error: %v", err) - } - updated, err := store.Get(d1.ID) - if err != nil { - t.Fatalf("Get updated error: %v", err) - } - if updated.UserNotes != "notes" || updated.UserSecrets["token"] != "abc" { - t.Fatalf("notes not updated: %#v", updated) - } - - d2 := &ResourceDiscovery{ - ID: MakeResourceID(ResourceTypeVM, "node1", "101"), - ResourceType: ResourceTypeVM, - ResourceID: "101", - HostID: "node1", - ServiceName: "VM", - } - if err := store.Save(d2); err != nil { - t.Fatalf("Save d2 error: %v", err) - } - - list, err := store.List() - if err != nil { - t.Fatalf("List error: %v", err) - } - if len(list) != 2 { - t.Fatalf("expected 2 discoveries, got %d", len(list)) - } - - byType, err := store.ListByType(ResourceTypeVM) - if err != nil { - t.Fatalf("ListByType error: %v", err) - } - if len(byType) != 1 || byType[0].ID != d2.ID { - t.Fatalf("unexpected ListByType: %#v", byType) - } - - byHost, err := store.ListByHost("host1") - if err != nil { - t.Fatalf("ListByHost error: %v", err) - } - if len(byHost) != 1 || byHost[0].ID != d1.ID { - t.Fatalf("unexpected ListByHost: %#v", byHost) - } - - summary := updated.ToSummary() - if summary.ID != d1.ID || !summary.HasUserNotes { - t.Fatalf("unexpected summary: %#v", summary) - } - - if err := store.Delete(d1.ID); err != nil { - t.Fatalf("Delete error: %v", err) - } - if store.Exists(d1.ID) { - t.Fatalf("expected discovery to be deleted") - } -} - -func TestStore_CryptoRoundTripAndPaths(t *testing.T) { - store, err := NewStore(t.TempDir()) - if err != nil { - t.Fatalf("NewStore error: %v", err) - } - store.crypto = fakeCrypto{} - - id := "docker:host1:app/name" - d := &ResourceDiscovery{ - ID: id, - ResourceType: ResourceTypeDocker, - ResourceID: "app/name", - HostID: "host1", - ServiceName: "App", - } - if err := store.Save(d); err != nil { - t.Fatalf("Save error: %v", err) - } - - path := store.getFilePath(id) - base := filepath.Base(path) - if strings.Contains(base, ":") || strings.Contains(base, "/") { - t.Fatalf("expected sanitized base filename, got %s", base) - } - - loaded, err := store.Get(id) - if err != nil { - t.Fatalf("Get error: %v", err) - } - if loaded == nil || loaded.ServiceName != "App" { - t.Fatalf("unexpected discovery: %#v", loaded) - } - - store.ClearCache() - if _, err := store.Get(id); err != nil { - t.Fatalf("Get with decrypt error: %v", err) - } - list, err := store.List() - if err != nil || len(list) != 1 { - t.Fatalf("List with decrypt error: %v len=%d", err, len(list)) - } -} - -func TestStore_NeedsRefreshAndGetMultiple(t *testing.T) { - store, err := NewStore(t.TempDir()) - if err != nil { - t.Fatalf("NewStore error: %v", err) - } - store.crypto = nil - - if !store.NeedsRefresh("missing", time.Minute) { - t.Fatalf("expected missing discovery to need refresh") - } - - d := &ResourceDiscovery{ - ID: MakeResourceID(ResourceTypeHost, "host1", "host1"), - ResourceType: ResourceTypeHost, - ResourceID: "host1", - HostID: "host1", - ServiceName: "Host", - } - if err := store.Save(d); err != nil { - t.Fatalf("Save error: %v", err) - } - - path := store.getFilePath(d.ID) - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("ReadFile error: %v", err) - } - var saved ResourceDiscovery - if err := json.Unmarshal(data, &saved); err != nil { - t.Fatalf("Unmarshal error: %v", err) - } - saved.UpdatedAt = time.Now().Add(-2 * time.Hour) - data, err = json.Marshal(&saved) - if err != nil { - t.Fatalf("Marshal error: %v", err) - } - if err := os.WriteFile(path, data, 0600); err != nil { - t.Fatalf("WriteFile error: %v", err) - } - - store.ClearCache() - if !store.NeedsRefresh(d.ID, time.Minute) { - t.Fatalf("expected old discovery to need refresh") - } - - ids := []string{d.ID, "missing"} - multi, err := store.GetMultiple(ids) - if err != nil { - t.Fatalf("GetMultiple error: %v", err) - } - if len(multi) != 1 || multi[0].ID != d.ID { - t.Fatalf("unexpected GetMultiple: %#v", multi) - } -} - -func TestStore_ErrorsAndListSkips(t *testing.T) { - dir := t.TempDir() - store, err := NewStore(dir) - if err != nil { - t.Fatalf("NewStore error: %v", err) - } - store.crypto = nil - - if err := store.Save(&ResourceDiscovery{}); err == nil { - t.Fatalf("expected error for empty ID") - } - - store.crypto = errorCrypto{} - if err := store.Save(&ResourceDiscovery{ID: "bad"}); err == nil { - t.Fatalf("expected encrypt error") - } - - store.crypto = nil - if _, err := store.Get("missing"); err != nil { - t.Fatalf("unexpected missing error: %v", err) - } - - d := &ResourceDiscovery{ - ID: MakeResourceID(ResourceTypeDocker, "host1", "web"), - ResourceType: ResourceTypeDocker, - ResourceID: "web", - HostID: "host1", - ServiceName: "Web", - UserSecrets: map[string]string{"token": "abc"}, - } - if err := store.Save(d); err != nil { - t.Fatalf("Save error: %v", err) - } - - // Corrupt file to force unmarshal error during List. - badPath := filepath.Join(store.dataDir, "bad.enc") - if err := os.WriteFile(badPath, []byte("{bad"), 0600); err != nil { - t.Fatalf("WriteFile error: %v", err) - } - if err := os.WriteFile(filepath.Join(store.dataDir, "note.txt"), []byte("skip"), 0600); err != nil { - t.Fatalf("WriteFile error: %v", err) - } - if err := os.WriteFile(filepath.Join(store.dataDir, "skip.enc.tmp"), []byte("skip"), 0600); err != nil { - t.Fatalf("WriteFile error: %v", err) - } - if err := os.MkdirAll(filepath.Join(store.dataDir, "dir"), 0700); err != nil { - t.Fatalf("MkdirAll error: %v", err) - } - unreadable := filepath.Join(store.dataDir, "unreadable.enc") - if err := os.WriteFile(unreadable, []byte("nope"), 0000); err != nil { - t.Fatalf("WriteFile error: %v", err) - } - - list, err := store.List() - if err != nil { - t.Fatalf("List error: %v", err) - } - if len(list) != 1 { - t.Fatalf("expected 1 discovery, got %d", len(list)) - } - - store.crypto = errorCrypto{} - list, err = store.List() - if err != nil { - t.Fatalf("List with crypto error: %v", err) - } - if len(list) != 0 { - t.Fatalf("expected crypto errors to skip entries") - } - - store.crypto = errorCrypto{} - store.ClearCache() - if _, err := store.Get(d.ID); err == nil { - t.Fatalf("expected decrypt error") - } - - store.crypto = nil - if got, err := store.GetByResource(ResourceTypeDocker, "host1", "web"); err != nil || got == nil { - t.Fatalf("GetByResource error: %v", err) - } - - if err := store.UpdateNotes(d.ID, "notes-only", nil); err != nil { - t.Fatalf("UpdateNotes error: %v", err) - } - updated, err := store.Get(d.ID) - if err != nil || updated.UserSecrets == nil { - t.Fatalf("expected secrets to be preserved: %#v err=%v", updated, err) - } - - store.crypto = errorCrypto{} - store.ClearCache() - if err := store.UpdateNotes(d.ID, "notes", nil); err == nil { - t.Fatalf("expected update notes error with crypto failure") - } - if got, err := store.GetMultiple([]string{d.ID}); err != nil || len(got) != 0 { - t.Fatalf("expected GetMultiple to skip errors") - } - - if err := store.UpdateNotes("missing", "notes", nil); err == nil { - t.Fatalf("expected error for missing discovery") - } - - if err := store.Delete("missing"); err != nil { - t.Fatalf("unexpected delete error: %v", err) - } -} - -func TestStore_NewStoreError(t *testing.T) { - dir := t.TempDir() - file := filepath.Join(dir, "file") - if err := os.WriteFile(file, []byte("x"), 0600); err != nil { - t.Fatalf("WriteFile error: %v", err) - } - - if _, err := NewStore(file); err == nil { - t.Fatalf("expected error for file data dir") - } -} - -func TestStore_NewStoreCryptoFailure(t *testing.T) { - orig := newCryptoManagerAt - newCryptoManagerAt = func(dataDir string) (*crypto.CryptoManager, error) { - manager, err := crypto.NewCryptoManagerAt(dataDir) - if err != nil { - return nil, err - } - return manager, os.ErrInvalid - } - t.Cleanup(func() { - newCryptoManagerAt = orig - }) - - store, err := NewStore(t.TempDir()) - if err != nil { - t.Fatalf("NewStore error: %v", err) - } - if store.crypto == nil { - t.Fatalf("expected crypto manager despite init warning") - } -} - -func TestStore_SaveMarshalError(t *testing.T) { - store, err := NewStore(t.TempDir()) - if err != nil { - t.Fatalf("NewStore error: %v", err) - } - store.crypto = nil - - orig := marshalDiscovery - marshalDiscovery = func(any) ([]byte, error) { - return nil, os.ErrInvalid - } - t.Cleanup(func() { - marshalDiscovery = orig - }) - - if err := store.Save(&ResourceDiscovery{ID: "marshal"}); err == nil { - t.Fatalf("expected marshal error") - } -} - -func TestStore_SaveAndGetErrors(t *testing.T) { - store, err := NewStore(t.TempDir()) - if err != nil { - t.Fatalf("NewStore error: %v", err) - } - store.crypto = nil - - id := MakeResourceID(ResourceTypeDocker, "host1", "web") - filePath := store.getFilePath(id) - if err := os.MkdirAll(filePath, 0700); err != nil { - t.Fatalf("MkdirAll error: %v", err) - } - if err := store.Save(&ResourceDiscovery{ID: id}); err == nil { - t.Fatalf("expected rename error") - } - - tmpFile := filepath.Join(t.TempDir(), "file") - if err := os.WriteFile(tmpFile, []byte("x"), 0600); err != nil { - t.Fatalf("WriteFile error: %v", err) - } - store.dataDir = tmpFile - if err := store.Save(&ResourceDiscovery{ID: "bad"}); err == nil { - t.Fatalf("expected write error") - } - - store.dataDir = t.TempDir() - store.crypto = nil - badPath := store.getFilePath("bad") - if err := os.WriteFile(badPath, []byte("{bad"), 0600); err != nil { - t.Fatalf("WriteFile error: %v", err) - } - if _, err := store.Get("bad"); err == nil { - t.Fatalf("expected unmarshal error") - } -} - -func TestStore_ListErrors(t *testing.T) { - store, err := NewStore(t.TempDir()) - if err != nil { - t.Fatalf("NewStore error: %v", err) - } - store.crypto = nil - - store.dataDir = filepath.Join(t.TempDir(), "missing") - list, err := store.List() - if err != nil || len(list) != 0 { - t.Fatalf("expected empty list for missing dir") - } - - file := filepath.Join(t.TempDir(), "file") - if err := os.WriteFile(file, []byte("x"), 0600); err != nil { - t.Fatalf("WriteFile error: %v", err) - } - store.dataDir = file - if _, err := store.List(); err == nil { - t.Fatalf("expected list error for file path") - } - if _, err := store.ListByType(ResourceTypeDocker); err == nil { - t.Fatalf("expected list by type error") - } - if _, err := store.ListByHost("host1"); err == nil { - t.Fatalf("expected list by host error") - } -} - -func TestStore_DeleteError(t *testing.T) { - store, err := NewStore(t.TempDir()) - if err != nil { - t.Fatalf("NewStore error: %v", err) - } - store.crypto = nil - - id := MakeResourceID(ResourceTypeDocker, "host1", "dir") - filePath := store.getFilePath(id) - if err := os.MkdirAll(filePath, 0700); err != nil { - t.Fatalf("MkdirAll error: %v", err) - } - nested := filepath.Join(filePath, "nested") - if err := os.WriteFile(nested, []byte("x"), 0600); err != nil { - t.Fatalf("WriteFile error: %v", err) - } - if err := store.Delete(id); err == nil { - t.Fatalf("expected delete error for non-empty dir") - } -} diff --git a/internal/aidiscovery/tools_adapter.go b/internal/aidiscovery/tools_adapter.go deleted file mode 100644 index 5658f7fe9..000000000 --- a/internal/aidiscovery/tools_adapter.go +++ /dev/null @@ -1,155 +0,0 @@ -package aidiscovery - -import ( - "github.com/rcourtman/pulse-go-rewrite/internal/ai/tools" -) - -// ToolsAdapter wraps Service to implement tools.DiscoverySource -type ToolsAdapter struct { - service *Service -} - -// NewToolsAdapter creates a new adapter for the discovery service -func NewToolsAdapter(service *Service) *ToolsAdapter { - if service == nil { - return nil - } - return &ToolsAdapter{service: service} -} - -// GetDiscovery implements tools.DiscoverySource -func (a *ToolsAdapter) GetDiscovery(id string) (tools.DiscoverySourceData, error) { - discovery, err := a.service.GetDiscovery(id) - if err != nil { - return tools.DiscoverySourceData{}, err - } - if discovery == nil { - return tools.DiscoverySourceData{}, nil - } - return a.convertToSourceData(discovery), nil -} - -// GetDiscoveryByResource implements tools.DiscoverySource -func (a *ToolsAdapter) GetDiscoveryByResource(resourceType, hostID, resourceID string) (tools.DiscoverySourceData, error) { - discovery, err := a.service.GetDiscoveryByResource(ResourceType(resourceType), hostID, resourceID) - if err != nil { - return tools.DiscoverySourceData{}, err - } - if discovery == nil { - return tools.DiscoverySourceData{}, nil - } - return a.convertToSourceData(discovery), nil -} - -// ListDiscoveries implements tools.DiscoverySource -func (a *ToolsAdapter) ListDiscoveries() ([]tools.DiscoverySourceData, error) { - discoveries, err := a.service.ListDiscoveries() - if err != nil { - return nil, err - } - return a.convertList(discoveries), nil -} - -// ListDiscoveriesByType implements tools.DiscoverySource -func (a *ToolsAdapter) ListDiscoveriesByType(resourceType string) ([]tools.DiscoverySourceData, error) { - discoveries, err := a.service.ListDiscoveriesByType(ResourceType(resourceType)) - if err != nil { - return nil, err - } - return a.convertList(discoveries), nil -} - -// ListDiscoveriesByHost implements tools.DiscoverySource -func (a *ToolsAdapter) ListDiscoveriesByHost(hostID string) ([]tools.DiscoverySourceData, error) { - discoveries, err := a.service.ListDiscoveriesByHost(hostID) - if err != nil { - return nil, err - } - return a.convertList(discoveries), nil -} - -// FormatForAIContext implements tools.DiscoverySource -func (a *ToolsAdapter) FormatForAIContext(sourceData []tools.DiscoverySourceData) string { - // Convert back to ResourceDiscovery for formatting - discoveries := make([]*ResourceDiscovery, 0, len(sourceData)) - for _, sd := range sourceData { - discoveries = append(discoveries, a.convertFromSourceData(sd)) - } - return FormatForAIContext(discoveries) -} - -func (a *ToolsAdapter) convertToSourceData(d *ResourceDiscovery) tools.DiscoverySourceData { - facts := make([]tools.DiscoverySourceFact, 0, len(d.Facts)) - for _, f := range d.Facts { - facts = append(facts, tools.DiscoverySourceFact{ - Category: string(f.Category), - Key: f.Key, - Value: f.Value, - Source: f.Source, - }) - } - - return tools.DiscoverySourceData{ - ID: d.ID, - ResourceType: string(d.ResourceType), - ResourceID: d.ResourceID, - HostID: d.HostID, - Hostname: d.Hostname, - ServiceType: d.ServiceType, - ServiceName: d.ServiceName, - ServiceVersion: d.ServiceVersion, - Category: string(d.Category), - CLIAccess: d.CLIAccess, - Facts: facts, - ConfigPaths: d.ConfigPaths, - DataPaths: d.DataPaths, - UserNotes: d.UserNotes, - Confidence: d.Confidence, - AIReasoning: d.AIReasoning, - DiscoveredAt: d.DiscoveredAt, - UpdatedAt: d.UpdatedAt, - } -} - -func (a *ToolsAdapter) convertFromSourceData(sd tools.DiscoverySourceData) *ResourceDiscovery { - facts := make([]DiscoveryFact, 0, len(sd.Facts)) - for _, f := range sd.Facts { - facts = append(facts, DiscoveryFact{ - Category: FactCategory(f.Category), - Key: f.Key, - Value: f.Value, - Source: f.Source, - }) - } - - return &ResourceDiscovery{ - ID: sd.ID, - ResourceType: ResourceType(sd.ResourceType), - ResourceID: sd.ResourceID, - HostID: sd.HostID, - Hostname: sd.Hostname, - ServiceType: sd.ServiceType, - ServiceName: sd.ServiceName, - ServiceVersion: sd.ServiceVersion, - Category: ServiceCategory(sd.Category), - CLIAccess: sd.CLIAccess, - Facts: facts, - ConfigPaths: sd.ConfigPaths, - DataPaths: sd.DataPaths, - UserNotes: sd.UserNotes, - Confidence: sd.Confidence, - AIReasoning: sd.AIReasoning, - DiscoveredAt: sd.DiscoveredAt, - UpdatedAt: sd.UpdatedAt, - } -} - -func (a *ToolsAdapter) convertList(discoveries []*ResourceDiscovery) []tools.DiscoverySourceData { - result := make([]tools.DiscoverySourceData, 0, len(discoveries)) - for _, d := range discoveries { - if d != nil { - result = append(result, a.convertToSourceData(d)) - } - } - return result -} diff --git a/internal/aidiscovery/types.go b/internal/aidiscovery/types.go deleted file mode 100644 index 48ca9aaf9..000000000 --- a/internal/aidiscovery/types.go +++ /dev/null @@ -1,236 +0,0 @@ -// Package discovery provides AI-powered infrastructure discovery capabilities. -// It discovers services, versions, configurations, and CLI access methods -// for VMs, LXCs, Docker containers, Kubernetes pods, and hosts. -package aidiscovery - -import ( - "fmt" - "time" -) - -// ResourceType identifies the type of infrastructure resource. -type ResourceType string - -const ( - ResourceTypeVM ResourceType = "vm" - ResourceTypeLXC ResourceType = "lxc" - ResourceTypeDocker ResourceType = "docker" - ResourceTypeK8s ResourceType = "k8s" - ResourceTypeHost ResourceType = "host" - ResourceTypeDockerVM ResourceType = "docker_vm" // Docker on a VM - ResourceTypeDockerLXC ResourceType = "docker_lxc" // Docker in an LXC -) - -// FactCategory categorizes discovery facts. -type FactCategory string - -const ( - FactCategoryVersion FactCategory = "version" - FactCategoryConfig FactCategory = "config" - FactCategoryService FactCategory = "service" - FactCategoryPort FactCategory = "port" - FactCategoryHardware FactCategory = "hardware" - FactCategoryNetwork FactCategory = "network" - FactCategoryStorage FactCategory = "storage" - FactCategoryDependency FactCategory = "dependency" - FactCategorySecurity FactCategory = "security" -) - -// ServiceCategory categorizes the type of service discovered. -type ServiceCategory string - -const ( - CategoryDatabase ServiceCategory = "database" - CategoryWebServer ServiceCategory = "web_server" - CategoryCache ServiceCategory = "cache" - CategoryMessageQueue ServiceCategory = "message_queue" - CategoryMonitoring ServiceCategory = "monitoring" - CategoryBackup ServiceCategory = "backup" - CategoryNVR ServiceCategory = "nvr" - CategoryStorage ServiceCategory = "storage" - CategoryContainer ServiceCategory = "container" - CategoryVirtualizer ServiceCategory = "virtualizer" - CategoryNetwork ServiceCategory = "network" - CategorySecurity ServiceCategory = "security" - CategoryMedia ServiceCategory = "media" - CategoryHomeAuto ServiceCategory = "home_automation" - CategoryUnknown ServiceCategory = "unknown" -) - -// ResourceDiscovery is the main data model for discovered resource information. -type ResourceDiscovery struct { - // Identity - ID string `json:"id"` // Unique ID: "lxc:minipc:101" - ResourceType ResourceType `json:"resource_type"` // vm, lxc, docker, k8s, host - ResourceID string `json:"resource_id"` // 101, container-name, etc. - HostID string `json:"host_id"` // Proxmox node name or host agent ID - Hostname string `json:"hostname"` // Human-readable host name - - // AI-discovered info - ServiceType string `json:"service_type"` // frigate, postgres, pbs - ServiceName string `json:"service_name"` // Human-readable name - ServiceVersion string `json:"service_version"` // v0.13.2 - Category ServiceCategory `json:"category"` // nvr, database, backup - CLIAccess string `json:"cli_access"` // pct exec 101 -- ... - - // Deep discovery facts - Facts []DiscoveryFact `json:"facts"` - ConfigPaths []string `json:"config_paths"` - DataPaths []string `json:"data_paths"` - Ports []PortInfo `json:"ports"` - - // User-added (also encrypted) - UserNotes string `json:"user_notes"` - UserSecrets map[string]string `json:"user_secrets"` // tokens, creds - - // Metadata - Confidence float64 `json:"confidence"` // 0-1 confidence score - AIReasoning string `json:"ai_reasoning"` // AI explanation - DiscoveredAt time.Time `json:"discovered_at"` // First discovery - UpdatedAt time.Time `json:"updated_at"` // Last update - ScanDuration int64 `json:"scan_duration"` // Scan duration in ms - - // Raw data for debugging/re-analysis - RawCommandOutput map[string]string `json:"raw_command_output,omitempty"` -} - -// DiscoveryFact represents a single discovered fact about a resource. -type DiscoveryFact struct { - Category FactCategory `json:"category"` // version, config, service, port - Key string `json:"key"` // e.g., "coral_tpu", "mqtt_broker" - Value string `json:"value"` // e.g., "/dev/apex_0", "mosquitto:1883" - Source string `json:"source"` // command that found this - Confidence float64 `json:"confidence"` // 0-1 confidence for this fact - DiscoveredAt time.Time `json:"discovered_at"` -} - -// PortInfo represents information about a listening port. -type PortInfo struct { - Port int `json:"port"` - Protocol string `json:"protocol"` // tcp, udp - Process string `json:"process"` // process name - Address string `json:"address"` // bind address -} - -// MakeResourceID creates a standardized resource ID. -func MakeResourceID(resourceType ResourceType, hostID, resourceID string) string { - return fmt.Sprintf("%s:%s:%s", resourceType, hostID, resourceID) -} - -// ParseResourceID parses a resource ID into its components. -func ParseResourceID(id string) (resourceType ResourceType, hostID, resourceID string, err error) { - var parts [3]string - count := 0 - start := 0 - for i, c := range id { - if c == ':' { - if count < 2 { - parts[count] = id[start:i] - count++ - start = i + 1 - } - } - } - if count == 2 { - parts[2] = id[start:] - return ResourceType(parts[0]), parts[1], parts[2], nil - } - return "", "", "", fmt.Errorf("invalid resource ID format: %s", id) -} - -// DiscoveryRequest represents a request to discover a resource. -type DiscoveryRequest struct { - ResourceType ResourceType `json:"resource_type"` - ResourceID string `json:"resource_id"` - HostID string `json:"host_id"` - Hostname string `json:"hostname"` - Force bool `json:"force"` // Force re-scan even if recent -} - -// DiscoveryStatus represents the status of a discovery scan. -type DiscoveryStatus string - -const ( - DiscoveryStatusPending DiscoveryStatus = "pending" - DiscoveryStatusRunning DiscoveryStatus = "running" - DiscoveryStatusCompleted DiscoveryStatus = "completed" - DiscoveryStatusFailed DiscoveryStatus = "failed" - DiscoveryStatusNotStarted DiscoveryStatus = "not_started" -) - -// DiscoveryProgress represents the progress of an ongoing discovery. -type DiscoveryProgress struct { - ResourceID string `json:"resource_id"` - Status DiscoveryStatus `json:"status"` - CurrentStep string `json:"current_step"` - TotalSteps int `json:"total_steps"` - CompletedSteps int `json:"completed_steps"` - StartedAt time.Time `json:"started_at"` - Error string `json:"error,omitempty"` -} - -// UpdateNotesRequest represents a request to update user notes. -type UpdateNotesRequest struct { - UserNotes string `json:"user_notes"` - UserSecrets map[string]string `json:"user_secrets,omitempty"` -} - -// DiscoverySummary provides a summary of discoveries for listing. -type DiscoverySummary struct { - ID string `json:"id"` - ResourceType ResourceType `json:"resource_type"` - ResourceID string `json:"resource_id"` - HostID string `json:"host_id"` - Hostname string `json:"hostname"` - ServiceType string `json:"service_type"` - ServiceName string `json:"service_name"` - ServiceVersion string `json:"service_version"` - Category ServiceCategory `json:"category"` - Confidence float64 `json:"confidence"` - HasUserNotes bool `json:"has_user_notes"` - UpdatedAt time.Time `json:"updated_at"` -} - -// ToSummary converts a full discovery to a summary. -func (d *ResourceDiscovery) ToSummary() DiscoverySummary { - return DiscoverySummary{ - ID: d.ID, - ResourceType: d.ResourceType, - ResourceID: d.ResourceID, - HostID: d.HostID, - Hostname: d.Hostname, - ServiceType: d.ServiceType, - ServiceName: d.ServiceName, - ServiceVersion: d.ServiceVersion, - Category: d.Category, - Confidence: d.Confidence, - HasUserNotes: d.UserNotes != "", - UpdatedAt: d.UpdatedAt, - } -} - -// AIAnalysisRequest is sent to the AI for analysis. -type AIAnalysisRequest struct { - ResourceType ResourceType `json:"resource_type"` - ResourceID string `json:"resource_id"` - HostID string `json:"host_id"` - Hostname string `json:"hostname"` - CommandOutputs map[string]string `json:"command_outputs"` - ExistingFacts []DiscoveryFact `json:"existing_facts,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` // Image, labels, etc. -} - -// AIAnalysisResponse is returned by the AI. -type AIAnalysisResponse struct { - ServiceType string `json:"service_type"` - ServiceName string `json:"service_name"` - ServiceVersion string `json:"service_version"` - Category ServiceCategory `json:"category"` - CLIAccess string `json:"cli_access"` - Facts []DiscoveryFact `json:"facts"` - ConfigPaths []string `json:"config_paths"` - DataPaths []string `json:"data_paths"` - Ports []PortInfo `json:"ports"` - Confidence float64 `json:"confidence"` - Reasoning string `json:"reasoning"` -} diff --git a/internal/aidiscovery/types_test.go b/internal/aidiscovery/types_test.go deleted file mode 100644 index 405ca10a0..000000000 --- a/internal/aidiscovery/types_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package aidiscovery - -import "testing" - -func TestResourceIDHelpers(t *testing.T) { - id := MakeResourceID(ResourceTypeDocker, "host1", "app") - if id != "docker:host1:app" { - t.Fatalf("unexpected id: %s", id) - } - - rt, host, res, err := ParseResourceID(id) - if err != nil { - t.Fatalf("ParseResourceID error: %v", err) - } - if rt != ResourceTypeDocker || host != "host1" || res != "app" { - t.Fatalf("unexpected parse result: %s %s %s", rt, host, res) - } - - if _, _, _, err := ParseResourceID("invalid"); err == nil { - t.Fatalf("expected parse error for invalid id") - } -}