mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-29 03:50:18 +00:00
saveToDisk used os.WriteFile which doesn't sync to disk before the atomic rename. On CI runners with aggressive filesystem caching this can leave the destination file with zero bytes, causing TestKnowledgeStore_SaveLoad to fail with "unexpected end of JSON input".
438 lines
12 KiB
Go
438 lines
12 KiB
Go
// Package adapters provides adapter implementations to connect existing stores
|
|
// and services to the interfaces required by the new AI intelligence packages.
|
|
package adapters
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/forecast"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/remediation"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
|
)
|
|
|
|
// ForecastDataAdapter adapts monitoring.MetricsHistory to the forecast.DataProvider interface.
|
|
// This allows the forecast service to access historical metric data.
|
|
type ForecastDataAdapter struct {
|
|
history *monitoring.MetricsHistory
|
|
}
|
|
|
|
// NewForecastDataAdapter creates a new adapter for forecast data.
|
|
func NewForecastDataAdapter(history *monitoring.MetricsHistory) *ForecastDataAdapter {
|
|
if history == nil {
|
|
return nil
|
|
}
|
|
return &ForecastDataAdapter{history: history}
|
|
}
|
|
|
|
// GetMetricHistory returns historical metric data for forecasting.
|
|
// It supports common metrics: cpu, memory, disk, netin, netout.
|
|
func (a *ForecastDataAdapter) GetMetricHistory(resourceID, metric string, from, to time.Time) ([]forecast.MetricDataPoint, error) {
|
|
if a.history == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
duration := to.Sub(from)
|
|
|
|
// Try guest metrics first (VMs and containers)
|
|
points := a.history.GetGuestMetrics(resourceID, metric, duration)
|
|
if len(points) == 0 {
|
|
// Try node metrics if not found as guest
|
|
points = a.history.GetNodeMetrics(resourceID, metric, duration)
|
|
}
|
|
|
|
if len(points) == 0 {
|
|
// Try storage metrics
|
|
allStorageMetrics := a.history.GetAllStorageMetrics(resourceID, duration)
|
|
if storagePoints, ok := allStorageMetrics[metric]; ok {
|
|
points = storagePoints
|
|
}
|
|
}
|
|
|
|
result := make([]forecast.MetricDataPoint, 0, len(points))
|
|
for _, p := range points {
|
|
// Filter by time window
|
|
if (p.Timestamp.Equal(from) || p.Timestamp.After(from)) &&
|
|
(p.Timestamp.Equal(to) || p.Timestamp.Before(to)) {
|
|
result = append(result, forecast.MetricDataPoint{
|
|
Timestamp: p.Timestamp,
|
|
Value: p.Value,
|
|
})
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// MetricsAdapter provides current metrics for resources.
|
|
// It implements metrics.MetricsProvider for the incident recorder.
|
|
type MetricsAdapter struct {
|
|
stateProvider ai.StateProvider
|
|
}
|
|
|
|
// NewMetricsAdapter creates a new adapter for current metrics.
|
|
func NewMetricsAdapter(stateProvider ai.StateProvider) *MetricsAdapter {
|
|
return &MetricsAdapter{stateProvider: stateProvider}
|
|
}
|
|
|
|
// GetMonitoredResourceIDs returns all resource IDs currently being monitored.
|
|
// This is used by the incident recorder to maintain pre-incident buffers for all resources.
|
|
func (a *MetricsAdapter) GetMonitoredResourceIDs() []string {
|
|
if a.stateProvider == nil {
|
|
return nil
|
|
}
|
|
|
|
state := a.stateProvider.GetState()
|
|
var ids []string
|
|
|
|
// Collect VM IDs
|
|
for _, vm := range state.VMs {
|
|
ids = append(ids, vm.ID)
|
|
}
|
|
|
|
// Collect container IDs
|
|
for _, ct := range state.Containers {
|
|
ids = append(ids, ct.ID)
|
|
}
|
|
|
|
// Collect node IDs
|
|
for _, node := range state.Nodes {
|
|
ids = append(ids, node.ID)
|
|
}
|
|
|
|
return ids
|
|
}
|
|
|
|
// GetCurrentMetrics returns current metrics for a resource.
|
|
func (a *MetricsAdapter) GetCurrentMetrics(resourceID string) (map[string]float64, error) {
|
|
if a.stateProvider == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
state := a.stateProvider.GetState()
|
|
|
|
metrics := make(map[string]float64)
|
|
|
|
// Check VMs
|
|
for _, vm := range state.VMs {
|
|
if vm.ID == resourceID || fmt.Sprintf("%d", vm.VMID) == resourceID {
|
|
metrics["cpu"] = vm.CPU
|
|
metrics["memory"] = vm.Memory.Usage
|
|
metrics["disk"] = vm.Disk.Usage
|
|
metrics["netin"] = float64(vm.NetworkIn)
|
|
metrics["netout"] = float64(vm.NetworkOut)
|
|
metrics["diskread"] = float64(vm.DiskRead)
|
|
metrics["diskwrite"] = float64(vm.DiskWrite)
|
|
return metrics, nil
|
|
}
|
|
}
|
|
|
|
// Check containers
|
|
for _, ct := range state.Containers {
|
|
if ct.ID == resourceID || fmt.Sprintf("%d", ct.VMID) == resourceID {
|
|
metrics["cpu"] = ct.CPU
|
|
metrics["memory"] = ct.Memory.Usage
|
|
metrics["disk"] = ct.Disk.Usage
|
|
metrics["netin"] = float64(ct.NetworkIn)
|
|
metrics["netout"] = float64(ct.NetworkOut)
|
|
metrics["diskread"] = float64(ct.DiskRead)
|
|
metrics["diskwrite"] = float64(ct.DiskWrite)
|
|
return metrics, nil
|
|
}
|
|
}
|
|
|
|
// Check nodes
|
|
for _, node := range state.Nodes {
|
|
if node.ID == resourceID || node.Name == resourceID {
|
|
metrics["cpu"] = node.CPU
|
|
metrics["memory"] = node.Memory.Usage
|
|
metrics["disk"] = node.Disk.Usage
|
|
return metrics, nil
|
|
}
|
|
}
|
|
|
|
// Check storage
|
|
for _, storage := range state.Storage {
|
|
if storage.ID == resourceID || storage.Name == resourceID {
|
|
metrics["disk"] = storage.Usage
|
|
metrics["used"] = float64(storage.Used)
|
|
metrics["total"] = float64(storage.Total)
|
|
return metrics, nil
|
|
}
|
|
}
|
|
|
|
return metrics, nil
|
|
}
|
|
|
|
// CommandExecutorAdapter adapts the agent execution system to remediation.CommandExecutor.
|
|
// This allows the remediation engine to execute commands on targets.
|
|
type CommandExecutorAdapter struct {
|
|
// For now, this is a placeholder. In a full implementation, this would
|
|
// use the agentexec package to run commands on Proxmox nodes/guests.
|
|
// Security note: Command execution should be carefully controlled.
|
|
}
|
|
|
|
// NewCommandExecutorAdapter creates a new adapter for command execution.
|
|
func NewCommandExecutorAdapter() *CommandExecutorAdapter {
|
|
return &CommandExecutorAdapter{}
|
|
}
|
|
|
|
// Execute runs a command on a target.
|
|
// Currently returns an error since direct command execution is not yet implemented.
|
|
// Full implementation would route to appropriate execution backend (SSH, PVE API, etc.)
|
|
func (a *CommandExecutorAdapter) Execute(ctx context.Context, target, command string) (string, error) {
|
|
// For safety, command execution is disabled by default.
|
|
// This would need to be implemented with proper safety checks and routing.
|
|
return "", &CommandExecutionDisabledError{
|
|
Target: target,
|
|
Command: command,
|
|
}
|
|
}
|
|
|
|
// CommandExecutionDisabledError indicates command execution is not enabled.
|
|
type CommandExecutionDisabledError struct {
|
|
Target string
|
|
Command string
|
|
}
|
|
|
|
func (e *CommandExecutionDisabledError) Error() string {
|
|
return "command execution is disabled - commands must be run manually"
|
|
}
|
|
|
|
// IncidentRecorderMCPAdapter adapts metrics.IncidentRecorder to tools.IncidentRecorderProvider
|
|
type IncidentRecorderMCPAdapter struct {
|
|
recorder IncidentRecorderSource
|
|
}
|
|
|
|
// IncidentRecorderSource defines what we need from an incident recorder
|
|
type IncidentRecorderSource interface {
|
|
GetWindowsForResource(resourceID string, limit int) []*IncidentWindowData
|
|
GetWindow(windowID string) *IncidentWindowData
|
|
}
|
|
|
|
// IncidentWindowData represents incident window data
|
|
type IncidentWindowData struct {
|
|
ID string
|
|
ResourceID string
|
|
ResourceName string
|
|
ResourceType string
|
|
TriggerType string
|
|
TriggerID string
|
|
StartTime time.Time
|
|
EndTime *time.Time
|
|
Status string
|
|
DataPoints []IncidentDataPointData
|
|
Summary *IncidentSummaryData
|
|
}
|
|
|
|
// IncidentDataPointData represents a single data point
|
|
type IncidentDataPointData struct {
|
|
Timestamp time.Time
|
|
Metrics map[string]float64
|
|
}
|
|
|
|
// IncidentSummaryData provides summary statistics
|
|
type IncidentSummaryData struct {
|
|
Duration time.Duration
|
|
DataPoints int
|
|
Peaks map[string]float64
|
|
Lows map[string]float64
|
|
Averages map[string]float64
|
|
Changes map[string]float64
|
|
}
|
|
|
|
// NewIncidentRecorderMCPAdapter creates a new incident recorder adapter
|
|
func NewIncidentRecorderMCPAdapter(recorder IncidentRecorderSource) *IncidentRecorderMCPAdapter {
|
|
return &IncidentRecorderMCPAdapter{recorder: recorder}
|
|
}
|
|
|
|
// GetWindowsForResource returns incident windows for a resource
|
|
func (a *IncidentRecorderMCPAdapter) GetWindowsForResource(resourceID string, limit int) []*IncidentWindowData {
|
|
if a.recorder == nil {
|
|
return nil
|
|
}
|
|
return a.recorder.GetWindowsForResource(resourceID, limit)
|
|
}
|
|
|
|
// GetWindow returns a specific incident window
|
|
func (a *IncidentRecorderMCPAdapter) GetWindow(windowID string) *IncidentWindowData {
|
|
if a.recorder == nil {
|
|
return nil
|
|
}
|
|
return a.recorder.GetWindow(windowID)
|
|
}
|
|
|
|
// EventCorrelatorMCPAdapter adapts proxmox.EventCorrelator to tools.EventCorrelatorProvider
|
|
type EventCorrelatorMCPAdapter struct {
|
|
correlator EventCorrelatorSource
|
|
}
|
|
|
|
// EventCorrelatorSource defines what we need from an event correlator
|
|
type EventCorrelatorSource interface {
|
|
GetCorrelationsForResource(resourceID string) []EventCorrelationData
|
|
GetEventsForResource(resourceID string, limit int) []ProxmoxEventData
|
|
}
|
|
|
|
// EventCorrelationData represents a correlation
|
|
type EventCorrelationData struct {
|
|
ID string
|
|
Explanation string
|
|
Confidence float64
|
|
ImpactedResources []string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// ProxmoxEventData represents a Proxmox event
|
|
type ProxmoxEventData struct {
|
|
ID string
|
|
Type string
|
|
Timestamp time.Time
|
|
Node string
|
|
ResourceID string
|
|
ResourceName string
|
|
ResourceType string
|
|
Status string
|
|
}
|
|
|
|
// NewEventCorrelatorMCPAdapter creates a new event correlator adapter
|
|
func NewEventCorrelatorMCPAdapter(correlator EventCorrelatorSource) *EventCorrelatorMCPAdapter {
|
|
return &EventCorrelatorMCPAdapter{correlator: correlator}
|
|
}
|
|
|
|
// GetCorrelationsForResource returns correlated events for a resource
|
|
func (a *EventCorrelatorMCPAdapter) GetCorrelationsForResource(resourceID string, window time.Duration) []EventCorrelationData {
|
|
if a.correlator == nil {
|
|
return nil
|
|
}
|
|
return a.correlator.GetCorrelationsForResource(resourceID)
|
|
}
|
|
|
|
// KnowledgeStore provides persistent storage for resource notes
|
|
type KnowledgeStore struct {
|
|
mu sync.RWMutex
|
|
entries map[string][]KnowledgeEntry
|
|
dataDir string
|
|
}
|
|
|
|
// KnowledgeEntry represents a stored note
|
|
type KnowledgeEntry struct {
|
|
ID string
|
|
ResourceID string
|
|
Note string
|
|
Category string
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// NewKnowledgeStore creates a new knowledge store
|
|
func NewKnowledgeStore(dataDir string) *KnowledgeStore {
|
|
store := &KnowledgeStore{
|
|
entries: make(map[string][]KnowledgeEntry),
|
|
dataDir: dataDir,
|
|
}
|
|
if dataDir != "" {
|
|
_ = store.loadFromDisk() // Ignore error, start fresh if load fails
|
|
}
|
|
return store
|
|
}
|
|
|
|
// SaveNote saves a note about a resource
|
|
func (s *KnowledgeStore) SaveNote(resourceID, note, category string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
entry := KnowledgeEntry{
|
|
ID: fmt.Sprintf("note-%d", time.Now().UnixNano()),
|
|
ResourceID: resourceID,
|
|
Note: note,
|
|
Category: category,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
s.entries[resourceID] = append(s.entries[resourceID], entry)
|
|
|
|
if s.dataDir != "" {
|
|
go s.saveToDisk() // Async save
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetKnowledge retrieves notes about a resource
|
|
func (s *KnowledgeStore) GetKnowledge(resourceID string, category string) []KnowledgeEntry {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
entries := s.entries[resourceID]
|
|
if category == "" {
|
|
return entries
|
|
}
|
|
|
|
// Filter by category
|
|
filtered := make([]KnowledgeEntry, 0)
|
|
for _, e := range entries {
|
|
if e.Category == category {
|
|
filtered = append(filtered, e)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func (s *KnowledgeStore) saveToDisk() {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if s.dataDir == "" {
|
|
return
|
|
}
|
|
|
|
data, err := json.Marshal(s.entries)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
path := filepath.Join(s.dataDir, "knowledge_store.json")
|
|
// Use atomic write (temp file + rename) to prevent corruption from concurrent saves.
|
|
tmp := path + ".tmp"
|
|
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if _, err := f.Write(data); err != nil {
|
|
f.Close()
|
|
return
|
|
}
|
|
if err := f.Sync(); err != nil {
|
|
f.Close()
|
|
return
|
|
}
|
|
f.Close()
|
|
_ = os.Rename(tmp, path)
|
|
}
|
|
|
|
func (s *KnowledgeStore) loadFromDisk() error {
|
|
if s.dataDir == "" {
|
|
return nil
|
|
}
|
|
|
|
path := filepath.Join(s.dataDir, "knowledge_store.json")
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return json.Unmarshal(data, &s.entries)
|
|
}
|
|
|
|
// Verify interfaces are implemented
|
|
var (
|
|
_ forecast.DataProvider = (*ForecastDataAdapter)(nil)
|
|
_ remediation.CommandExecutor = (*CommandExecutorAdapter)(nil)
|
|
)
|