mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 08:57:12 +00:00
1179 lines
33 KiB
Go
1179 lines
33 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/recovery"
|
|
recoverymanager "github.com/rcourtman/pulse-go-rewrite/internal/recovery/manager"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
)
|
|
|
|
// AlertManagerMCPAdapter adapts alerts.Manager to MCP AlertProvider interface
|
|
type AlertManagerMCPAdapter struct {
|
|
manager AlertManager
|
|
}
|
|
|
|
// AlertManager interface matches what alerts.Manager provides
|
|
type AlertManager interface {
|
|
GetActiveAlerts() []alerts.Alert
|
|
GetRecentlyResolved() []models.ResolvedAlert
|
|
}
|
|
|
|
// NewAlertManagerMCPAdapter creates a new adapter for alert manager
|
|
func NewAlertManagerMCPAdapter(manager AlertManager) *AlertManagerMCPAdapter {
|
|
if manager == nil {
|
|
return nil
|
|
}
|
|
return &AlertManagerMCPAdapter{manager: manager}
|
|
}
|
|
|
|
// GetActiveAlerts implements mcp.AlertProvider
|
|
func (a *AlertManagerMCPAdapter) GetActiveAlerts() []ActiveAlert {
|
|
if a.manager == nil {
|
|
return nil
|
|
}
|
|
|
|
activeAlerts := a.manager.GetActiveAlerts()
|
|
result := make([]ActiveAlert, 0, len(activeAlerts))
|
|
|
|
for _, alert := range activeAlerts {
|
|
result = append(result, ActiveAlert{
|
|
ID: alert.ID,
|
|
ResourceID: alert.ResourceID,
|
|
ResourceName: alert.ResourceName,
|
|
Type: alert.Type,
|
|
Severity: string(alert.Level),
|
|
Value: alert.Value,
|
|
Threshold: alert.Threshold,
|
|
StartTime: alert.StartTime,
|
|
Message: alert.Message,
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// GetRecentlyResolved implements mcp.AlertProvider
|
|
func (a *AlertManagerMCPAdapter) GetRecentlyResolved(minutes int) []models.ResolvedAlert {
|
|
if a.manager == nil {
|
|
return nil
|
|
}
|
|
return a.manager.GetRecentlyResolved()
|
|
}
|
|
|
|
// GuestConfigSource provides guest configuration data with context.
|
|
type GuestConfigSource interface {
|
|
GetGuestConfig(ctx context.Context, guestType, instance, node string, vmID int) (map[string]interface{}, error)
|
|
}
|
|
|
|
// GuestConfigMCPAdapter adapts monitoring config access to MCP GuestConfigProvider interface.
|
|
type GuestConfigMCPAdapter struct {
|
|
source GuestConfigSource
|
|
timeout time.Duration
|
|
}
|
|
|
|
// NewGuestConfigMCPAdapter creates a new adapter for guest config data.
|
|
func NewGuestConfigMCPAdapter(source GuestConfigSource) *GuestConfigMCPAdapter {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
return &GuestConfigMCPAdapter{
|
|
source: source,
|
|
timeout: 5 * time.Second,
|
|
}
|
|
}
|
|
|
|
// GetGuestConfig implements mcp.GuestConfigProvider.
|
|
func (a *GuestConfigMCPAdapter) GetGuestConfig(guestType, instance, node string, vmID int) (map[string]interface{}, error) {
|
|
if a == nil || a.source == nil {
|
|
return nil, fmt.Errorf("guest config source not available")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), a.timeout)
|
|
defer cancel()
|
|
return a.source.GetGuestConfig(ctx, guestType, instance, node, vmID)
|
|
}
|
|
|
|
// BackupMCPAdapter adapts backup data sources to MCP BackupProvider interface.
|
|
// Uses functional getters so callers can wire ReadState or StateSnapshot sources.
|
|
type BackupMCPAdapter struct {
|
|
getBackups func() models.Backups
|
|
getPBSInstances func() []models.PBSInstance
|
|
}
|
|
|
|
// NewBackupMCPAdapter creates a new adapter for backup data.
|
|
// Both getters must be non-nil; returns nil if either is nil.
|
|
func NewBackupMCPAdapter(getBackups func() models.Backups, getPBSInstances func() []models.PBSInstance) *BackupMCPAdapter {
|
|
if getBackups == nil || getPBSInstances == nil {
|
|
return nil
|
|
}
|
|
return &BackupMCPAdapter{getBackups: getBackups, getPBSInstances: getPBSInstances}
|
|
}
|
|
|
|
// GetBackups implements mcp.BackupProvider
|
|
func (a *BackupMCPAdapter) GetBackups() models.Backups {
|
|
if a.getBackups == nil {
|
|
return models.Backups{}
|
|
}
|
|
return a.getBackups()
|
|
}
|
|
|
|
// GetPBSInstances implements mcp.BackupProvider
|
|
func (a *BackupMCPAdapter) GetPBSInstances() []models.PBSInstance {
|
|
if a.getPBSInstances == nil {
|
|
return nil
|
|
}
|
|
return a.getPBSInstances()
|
|
}
|
|
|
|
// ReplicationMCPAdapter adapts replication data sources to MCP ReplicationProvider.
|
|
// Uses a functional getter so callers can wire ReadState or StateSnapshot sources.
|
|
type ReplicationMCPAdapter struct {
|
|
getJobs func() []models.ReplicationJob
|
|
}
|
|
|
|
// NewReplicationMCPAdapter creates a new adapter for replication data.
|
|
func NewReplicationMCPAdapter(getJobs func() []models.ReplicationJob) *ReplicationMCPAdapter {
|
|
if getJobs == nil {
|
|
return nil
|
|
}
|
|
return &ReplicationMCPAdapter{getJobs: getJobs}
|
|
}
|
|
|
|
// GetReplicationJobs implements ReplicationProvider.
|
|
func (a *ReplicationMCPAdapter) GetReplicationJobs() []models.ReplicationJob {
|
|
if a.getJobs == nil {
|
|
return nil
|
|
}
|
|
return a.getJobs()
|
|
}
|
|
|
|
// ConnectionHealthMCPAdapter adapts connection health data sources to MCP ConnectionHealthProvider.
|
|
// Uses a functional getter so callers can wire ReadState or StateSnapshot sources.
|
|
type ConnectionHealthMCPAdapter struct {
|
|
getHealth func() map[string]bool
|
|
}
|
|
|
|
// NewConnectionHealthMCPAdapter creates a new adapter for connection health data.
|
|
func NewConnectionHealthMCPAdapter(getHealth func() map[string]bool) *ConnectionHealthMCPAdapter {
|
|
if getHealth == nil {
|
|
return nil
|
|
}
|
|
return &ConnectionHealthMCPAdapter{getHealth: getHealth}
|
|
}
|
|
|
|
// GetConnectionHealth implements ConnectionHealthProvider.
|
|
func (a *ConnectionHealthMCPAdapter) GetConnectionHealth() map[string]bool {
|
|
if a.getHealth == nil {
|
|
return nil
|
|
}
|
|
return a.getHealth()
|
|
}
|
|
|
|
// RecoveryPointsMCPAdapter provides MCP tools access to persisted recovery points.
|
|
// It is org-scoped to avoid leaking cross-tenant data.
|
|
type RecoveryPointsMCPAdapter struct {
|
|
manager *recoverymanager.Manager
|
|
orgID string
|
|
timeout time.Duration
|
|
}
|
|
|
|
func NewRecoveryPointsMCPAdapter(manager *recoverymanager.Manager, orgID string) *RecoveryPointsMCPAdapter {
|
|
if manager == nil {
|
|
return nil
|
|
}
|
|
if orgID == "" {
|
|
orgID = "default"
|
|
}
|
|
return &RecoveryPointsMCPAdapter{
|
|
manager: manager,
|
|
orgID: orgID,
|
|
timeout: 5 * time.Second,
|
|
}
|
|
}
|
|
|
|
func (a *RecoveryPointsMCPAdapter) ListPoints(ctx context.Context, opts recovery.ListPointsOptions) ([]recovery.RecoveryPoint, int, error) {
|
|
if a == nil || a.manager == nil {
|
|
return nil, 0, fmt.Errorf("recovery points provider not available")
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, a.timeout)
|
|
defer cancel()
|
|
|
|
store, err := a.manager.StoreForOrg(a.orgID)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return store.ListPoints(ctx, opts)
|
|
}
|
|
|
|
// DiskHealthMCPAdapter adapts unified read-state hosts to MCP DiskHealthProvider.
|
|
type DiskHealthMCPAdapter struct {
|
|
readState unifiedresources.ReadState
|
|
}
|
|
|
|
// NewDiskHealthMCPAdapter creates a new adapter for disk health data
|
|
func NewDiskHealthMCPAdapter(readState unifiedresources.ReadState) *DiskHealthMCPAdapter {
|
|
if readState == nil {
|
|
return nil
|
|
}
|
|
return &DiskHealthMCPAdapter{readState: readState}
|
|
}
|
|
|
|
// GetHosts implements mcp.DiskHealthProvider
|
|
func (a *DiskHealthMCPAdapter) GetHosts() []*unifiedresources.HostView {
|
|
if a.readState == nil {
|
|
return nil
|
|
}
|
|
return a.readState.Hosts()
|
|
}
|
|
|
|
// RawMetricPoint represents a single metric value at a point in time
|
|
type RawMetricPoint struct {
|
|
Value float64
|
|
Timestamp time.Time
|
|
}
|
|
|
|
// MetricsSource provides access to historical metrics data
|
|
type MetricsSource interface {
|
|
GetGuestMetrics(guestID string, metricType string, duration time.Duration) []RawMetricPoint
|
|
GetNodeMetrics(nodeID string, metricType string, duration time.Duration) []RawMetricPoint
|
|
GetAllGuestMetrics(guestID string, duration time.Duration) map[string][]RawMetricPoint
|
|
}
|
|
|
|
// MetricsHistoryMCPAdapter adapts the metrics history to MCP MetricsHistoryProvider interface
|
|
type MetricsHistoryMCPAdapter struct {
|
|
readState unifiedresources.ReadState
|
|
metricsSource MetricsSource
|
|
}
|
|
|
|
// NewMetricsHistoryMCPAdapter creates a new adapter for metrics history.
|
|
// readState is required for iterating VMs/Containers/Nodes in GetAllMetricsSummary.
|
|
func NewMetricsHistoryMCPAdapter(metricsSource MetricsSource, readState unifiedresources.ReadState) *MetricsHistoryMCPAdapter {
|
|
if metricsSource == nil || readState == nil {
|
|
return nil
|
|
}
|
|
return &MetricsHistoryMCPAdapter{
|
|
readState: readState,
|
|
metricsSource: metricsSource,
|
|
}
|
|
}
|
|
|
|
// GetResourceMetrics implements mcp.MetricsHistoryProvider
|
|
func (a *MetricsHistoryMCPAdapter) GetResourceMetrics(resourceID string, period time.Duration) ([]MetricPoint, error) {
|
|
if a.metricsSource == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
// Try guest metrics first (VMs and containers)
|
|
allMetrics := a.metricsSource.GetAllGuestMetrics(resourceID, period)
|
|
if len(allMetrics) == 0 {
|
|
// Try node metrics
|
|
cpuPoints := a.metricsSource.GetNodeMetrics(resourceID, "cpu", period)
|
|
memPoints := a.metricsSource.GetNodeMetrics(resourceID, "memory", period)
|
|
if len(cpuPoints) > 0 || len(memPoints) > 0 {
|
|
allMetrics = map[string][]RawMetricPoint{
|
|
"cpu": cpuPoints,
|
|
"memory": memPoints,
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(allMetrics) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// Merge metrics by timestamp
|
|
return mergeMetricsByTimestamp(allMetrics), nil
|
|
}
|
|
|
|
// GetAllMetricsSummary implements mcp.MetricsHistoryProvider.
|
|
// Uses ReadState to iterate VMs, Containers, and Nodes.
|
|
func (a *MetricsHistoryMCPAdapter) GetAllMetricsSummary(period time.Duration) (map[string]ResourceMetricsSummary, error) {
|
|
if a.metricsSource == nil || a.readState == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
result := make(map[string]ResourceMetricsSummary)
|
|
|
|
for _, vm := range a.readState.VMs() {
|
|
vmID := fmt.Sprintf("%d", vm.VMID())
|
|
if summary := a.computeSummary(vmID, vm.Name(), "vm", period); summary != nil {
|
|
result[vmID] = *summary
|
|
}
|
|
}
|
|
for _, ct := range a.readState.Containers() {
|
|
ctID := fmt.Sprintf("%d", ct.VMID())
|
|
if summary := a.computeSummary(ctID, ct.Name(), "system-container", period); summary != nil {
|
|
result[ctID] = *summary
|
|
}
|
|
}
|
|
// Nodes: SourceID() returns the legacy node ID that MetricsSource indexes by.
|
|
for _, node := range a.readState.Nodes() {
|
|
nodeID := node.SourceID()
|
|
if nodeID == "" {
|
|
continue
|
|
}
|
|
if summary := a.computeSummary(nodeID, node.Name(), "node", period); summary != nil {
|
|
result[nodeID] = *summary
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (a *MetricsHistoryMCPAdapter) computeSummary(resourceID, resourceName, resourceType string, period time.Duration) *ResourceMetricsSummary {
|
|
var cpuPoints, memPoints []RawMetricPoint
|
|
|
|
if resourceType == "node" {
|
|
cpuPoints = a.metricsSource.GetNodeMetrics(resourceID, "cpu", period)
|
|
memPoints = a.metricsSource.GetNodeMetrics(resourceID, "memory", period)
|
|
} else {
|
|
cpuPoints = a.metricsSource.GetGuestMetrics(resourceID, "cpu", period)
|
|
memPoints = a.metricsSource.GetGuestMetrics(resourceID, "memory", period)
|
|
}
|
|
|
|
if len(cpuPoints) == 0 && len(memPoints) == 0 {
|
|
return nil
|
|
}
|
|
|
|
summary := &ResourceMetricsSummary{
|
|
ResourceID: resourceID,
|
|
ResourceName: resourceName,
|
|
ResourceType: resourceType,
|
|
Trend: "stable",
|
|
}
|
|
|
|
if len(cpuPoints) > 0 {
|
|
summary.AvgCPU, summary.MaxCPU = computeStats(cpuPoints)
|
|
summary.Trend = computeTrend(cpuPoints)
|
|
}
|
|
if len(memPoints) > 0 {
|
|
summary.AvgMemory, summary.MaxMemory = computeStats(memPoints)
|
|
}
|
|
|
|
return summary
|
|
}
|
|
|
|
func mergeMetricsByTimestamp(allMetrics map[string][]RawMetricPoint) []MetricPoint {
|
|
// Build a map of timestamp -> MetricPoint
|
|
pointsByTime := make(map[int64]*MetricPoint)
|
|
|
|
for metricType, points := range allMetrics {
|
|
for _, p := range points {
|
|
ts := p.Timestamp.Unix()
|
|
if _, exists := pointsByTime[ts]; !exists {
|
|
pointsByTime[ts] = &MetricPoint{Timestamp: p.Timestamp}
|
|
}
|
|
switch metricType {
|
|
case "cpu":
|
|
pointsByTime[ts].CPU = p.Value
|
|
case "memory":
|
|
pointsByTime[ts].Memory = p.Value
|
|
case "disk":
|
|
pointsByTime[ts].Disk = p.Value
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert to slice and sort by timestamp
|
|
result := make([]MetricPoint, 0, len(pointsByTime))
|
|
for _, p := range pointsByTime {
|
|
result = append(result, *p)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func computeStats(points []RawMetricPoint) (avg, max float64) {
|
|
if len(points) == 0 {
|
|
return 0, 0
|
|
}
|
|
|
|
var sum float64
|
|
for _, p := range points {
|
|
sum += p.Value
|
|
if p.Value > max {
|
|
max = p.Value
|
|
}
|
|
}
|
|
avg = sum / float64(len(points))
|
|
return avg, max
|
|
}
|
|
|
|
func computeTrend(points []RawMetricPoint) string {
|
|
if len(points) < 2 {
|
|
return "stable"
|
|
}
|
|
|
|
// Compare first quarter average to last quarter average
|
|
quarter := len(points) / 4
|
|
if quarter < 1 {
|
|
quarter = 1
|
|
}
|
|
|
|
var firstSum, lastSum float64
|
|
for i := 0; i < quarter; i++ {
|
|
firstSum += points[i].Value
|
|
}
|
|
for i := len(points) - quarter; i < len(points); i++ {
|
|
lastSum += points[i].Value
|
|
}
|
|
|
|
firstAvg := firstSum / float64(quarter)
|
|
lastAvg := lastSum / float64(quarter)
|
|
|
|
diff := lastAvg - firstAvg
|
|
threshold := 5.0 // 5% change threshold
|
|
|
|
if diff > threshold {
|
|
return "growing"
|
|
} else if diff < -threshold {
|
|
return "declining"
|
|
}
|
|
return "stable"
|
|
}
|
|
|
|
// ========== Baseline Provider Adapter ==========
|
|
|
|
// BaselineSource provides access to baseline data
|
|
type BaselineSource interface {
|
|
GetBaseline(resourceID, metric string) (mean, stddev float64, sampleCount int, ok bool)
|
|
GetAllBaselines() map[string]map[string]BaselineData
|
|
}
|
|
|
|
// BaselineData represents baseline data from the source
|
|
type BaselineData struct {
|
|
Mean float64
|
|
StdDev float64
|
|
SampleCount int
|
|
}
|
|
|
|
// BaselineMCPAdapter adapts baseline.Store to MCP BaselineProvider interface
|
|
type BaselineMCPAdapter struct {
|
|
source BaselineSource
|
|
}
|
|
|
|
// NewBaselineMCPAdapter creates a new adapter for baseline data
|
|
func NewBaselineMCPAdapter(source BaselineSource) *BaselineMCPAdapter {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
return &BaselineMCPAdapter{source: source}
|
|
}
|
|
|
|
// GetBaseline implements mcp.BaselineProvider
|
|
func (a *BaselineMCPAdapter) GetBaseline(resourceID, metric string) *MetricBaseline {
|
|
if a.source == nil {
|
|
return nil
|
|
}
|
|
|
|
mean, stddev, _, ok := a.source.GetBaseline(resourceID, metric)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
return &MetricBaseline{
|
|
Mean: mean,
|
|
StdDev: stddev,
|
|
Min: mean - 2*stddev, // Approximate
|
|
Max: mean + 2*stddev, // Approximate
|
|
}
|
|
}
|
|
|
|
// GetAllBaselines implements mcp.BaselineProvider
|
|
func (a *BaselineMCPAdapter) GetAllBaselines() map[string]map[string]*MetricBaseline {
|
|
if a.source == nil {
|
|
return nil
|
|
}
|
|
|
|
sourceData := a.source.GetAllBaselines()
|
|
if sourceData == nil {
|
|
return nil
|
|
}
|
|
|
|
result := make(map[string]map[string]*MetricBaseline)
|
|
for resourceID, metrics := range sourceData {
|
|
result[resourceID] = make(map[string]*MetricBaseline)
|
|
for metric, data := range metrics {
|
|
result[resourceID][metric] = &MetricBaseline{
|
|
Mean: data.Mean,
|
|
StdDev: data.StdDev,
|
|
Min: data.Mean - 2*data.StdDev,
|
|
Max: data.Mean + 2*data.StdDev,
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// ========== Pattern Provider Adapter ==========
|
|
|
|
// PatternSource provides access to pattern and prediction data
|
|
type PatternSource interface {
|
|
GetPatterns() []PatternData
|
|
GetPredictions() []PredictionData
|
|
}
|
|
|
|
// PatternData represents pattern data from the source
|
|
type PatternData struct {
|
|
ResourceID string
|
|
PatternType string
|
|
Description string
|
|
Confidence float64
|
|
LastSeen time.Time
|
|
}
|
|
|
|
// PredictionData represents prediction data from the source
|
|
type PredictionData struct {
|
|
ResourceID string
|
|
IssueType string
|
|
PredictedTime time.Time
|
|
Confidence float64
|
|
Recommendation string
|
|
}
|
|
|
|
// PatternMCPAdapter adapts patterns.Detector to MCP PatternProvider interface
|
|
type PatternMCPAdapter struct {
|
|
source PatternSource
|
|
readState unifiedresources.ReadState
|
|
}
|
|
|
|
// NewPatternMCPAdapter creates a new adapter for pattern data.
|
|
// readState is optional: when provided, resource name lookups resolve via
|
|
// ReadState views; when nil, raw resource IDs are returned as names.
|
|
func NewPatternMCPAdapter(source PatternSource, readState unifiedresources.ReadState) *PatternMCPAdapter {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
return &PatternMCPAdapter{source: source, readState: readState}
|
|
}
|
|
|
|
// GetPatterns implements mcp.PatternProvider
|
|
func (a *PatternMCPAdapter) GetPatterns() []Pattern {
|
|
if a.source == nil {
|
|
return nil
|
|
}
|
|
|
|
sourcePatterns := a.source.GetPatterns()
|
|
if sourcePatterns == nil {
|
|
return nil
|
|
}
|
|
|
|
result := make([]Pattern, 0, len(sourcePatterns))
|
|
for _, p := range sourcePatterns {
|
|
result = append(result, Pattern{
|
|
ResourceID: p.ResourceID,
|
|
ResourceName: a.getResourceName(p.ResourceID),
|
|
PatternType: p.PatternType,
|
|
Description: p.Description,
|
|
Confidence: p.Confidence,
|
|
LastSeen: p.LastSeen,
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// GetPredictions implements mcp.PatternProvider
|
|
func (a *PatternMCPAdapter) GetPredictions() []Prediction {
|
|
if a.source == nil {
|
|
return nil
|
|
}
|
|
|
|
sourcePredictions := a.source.GetPredictions()
|
|
if sourcePredictions == nil {
|
|
return nil
|
|
}
|
|
|
|
result := make([]Prediction, 0, len(sourcePredictions))
|
|
for _, p := range sourcePredictions {
|
|
result = append(result, Prediction{
|
|
ResourceID: p.ResourceID,
|
|
ResourceName: a.getResourceName(p.ResourceID),
|
|
IssueType: p.IssueType,
|
|
PredictedTime: p.PredictedTime,
|
|
Confidence: p.Confidence,
|
|
Recommendation: p.Recommendation,
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func (a *PatternMCPAdapter) getResourceName(resourceID string) string {
|
|
if a.readState == nil {
|
|
return resourceID
|
|
}
|
|
for _, vm := range a.readState.VMs() {
|
|
if fmt.Sprintf("%d", vm.VMID()) == resourceID {
|
|
return vm.Name()
|
|
}
|
|
}
|
|
for _, ct := range a.readState.Containers() {
|
|
if fmt.Sprintf("%d", ct.VMID()) == resourceID {
|
|
return ct.Name()
|
|
}
|
|
}
|
|
// Nodes: SourceID() returns the legacy node ID that patterns store.
|
|
for _, node := range a.readState.Nodes() {
|
|
if node.SourceID() == resourceID {
|
|
return node.Name()
|
|
}
|
|
}
|
|
return resourceID
|
|
}
|
|
|
|
// ========== Findings Manager Adapter ==========
|
|
|
|
// FindingsManagerSource provides findings management operations
|
|
type FindingsManagerSource interface {
|
|
ResolveFinding(findingID, note string) error
|
|
DismissFinding(findingID, reason, note string) error
|
|
}
|
|
|
|
// FindingsManagerMCPAdapter adapts PatrolService to MCP FindingsManager interface
|
|
type FindingsManagerMCPAdapter struct {
|
|
source FindingsManagerSource
|
|
}
|
|
|
|
// NewFindingsManagerMCPAdapter creates a new adapter for findings management
|
|
func NewFindingsManagerMCPAdapter(source FindingsManagerSource) *FindingsManagerMCPAdapter {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
return &FindingsManagerMCPAdapter{source: source}
|
|
}
|
|
|
|
// ResolveFinding implements mcp.FindingsManager
|
|
func (a *FindingsManagerMCPAdapter) ResolveFinding(findingID, note string) error {
|
|
if a.source == nil {
|
|
return fmt.Errorf("findings manager not available")
|
|
}
|
|
return a.source.ResolveFinding(findingID, note)
|
|
}
|
|
|
|
// DismissFinding implements mcp.FindingsManager
|
|
func (a *FindingsManagerMCPAdapter) DismissFinding(findingID, reason, note string) error {
|
|
if a.source == nil {
|
|
return fmt.Errorf("findings manager not available")
|
|
}
|
|
return a.source.DismissFinding(findingID, reason, note)
|
|
}
|
|
|
|
// ========== Metadata Updater Adapter ==========
|
|
|
|
// MetadataUpdaterSource provides metadata update operations
|
|
type MetadataUpdaterSource interface {
|
|
SetResourceURL(resourceType, resourceID, url string) error
|
|
}
|
|
|
|
// MetadataUpdaterMCPAdapter adapts ai.Service to MCP MetadataUpdater interface
|
|
type MetadataUpdaterMCPAdapter struct {
|
|
source MetadataUpdaterSource
|
|
}
|
|
|
|
// NewMetadataUpdaterMCPAdapter creates a new adapter for metadata updates
|
|
func NewMetadataUpdaterMCPAdapter(source MetadataUpdaterSource) *MetadataUpdaterMCPAdapter {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
return &MetadataUpdaterMCPAdapter{source: source}
|
|
}
|
|
|
|
// SetResourceURL implements mcp.MetadataUpdater
|
|
func (a *MetadataUpdaterMCPAdapter) SetResourceURL(resourceType, resourceID, url string) error {
|
|
if a.source == nil {
|
|
return fmt.Errorf("metadata updater not available")
|
|
}
|
|
return a.source.SetResourceURL(resourceType, resourceID, url)
|
|
}
|
|
|
|
// ========== Updates Provider Adapter ==========
|
|
|
|
// UpdatesCommandRunner is the subset of Monitor methods needed for Docker update commands.
|
|
// It does not include GetState — Docker host iteration uses a functional getter instead.
|
|
type UpdatesCommandRunner interface {
|
|
QueueDockerCheckUpdatesCommand(hostID string) (models.DockerHostCommandStatus, error)
|
|
QueueDockerContainerUpdateCommand(hostID, containerID, containerName string) (models.DockerHostCommandStatus, error)
|
|
}
|
|
|
|
// UpdatesConfig provides configuration for update operations
|
|
type UpdatesConfig interface {
|
|
IsDockerUpdateActionsEnabled() bool
|
|
}
|
|
|
|
// UpdatesMCPAdapter adapts Monitor to MCP UpdatesProvider interface.
|
|
// Uses a functional getter for Docker host iteration (decoupled from GetState)
|
|
// and a command runner for Docker update operations.
|
|
type UpdatesMCPAdapter struct {
|
|
readState unifiedresources.ReadState
|
|
commands UpdatesCommandRunner
|
|
config UpdatesConfig
|
|
}
|
|
|
|
// NewUpdatesMCPAdapter creates a new adapter for update operations.
|
|
// readState provides Docker host/container views; commands provides update command execution.
|
|
func NewUpdatesMCPAdapter(readState unifiedresources.ReadState, commands UpdatesCommandRunner, config UpdatesConfig) *UpdatesMCPAdapter {
|
|
if readState == nil || commands == nil {
|
|
return nil
|
|
}
|
|
return &UpdatesMCPAdapter{readState: readState, commands: commands, config: config}
|
|
}
|
|
|
|
// GetPendingUpdates implements mcp.UpdatesProvider
|
|
func (a *UpdatesMCPAdapter) GetPendingUpdates(hostID string) []ContainerUpdateInfo {
|
|
if a.readState == nil {
|
|
return nil
|
|
}
|
|
|
|
hostLabels := make(map[string]string)
|
|
for _, host := range a.readState.DockerHosts() {
|
|
if host == nil {
|
|
continue
|
|
}
|
|
label := strings.TrimSpace(host.Name())
|
|
if label == "" {
|
|
label = strings.TrimSpace(host.Hostname())
|
|
}
|
|
if label == "" {
|
|
label = strings.TrimSpace(host.ID())
|
|
}
|
|
if resourceID := strings.TrimSpace(host.ID()); resourceID != "" {
|
|
hostLabels[resourceID] = label
|
|
}
|
|
if sourceID := strings.TrimSpace(host.HostSourceID()); sourceID != "" {
|
|
hostLabels[sourceID] = label
|
|
}
|
|
}
|
|
|
|
var updates []ContainerUpdateInfo
|
|
|
|
for _, container := range a.readState.DockerContainers() {
|
|
if container == nil {
|
|
continue
|
|
}
|
|
targetID := strings.TrimSpace(container.HostSourceID())
|
|
if targetID == "" {
|
|
targetID = strings.TrimSpace(container.ParentID())
|
|
}
|
|
if hostID != "" && targetID != hostID && strings.TrimSpace(container.ParentID()) != hostID {
|
|
continue
|
|
}
|
|
|
|
updateStatus := container.UpdateStatus()
|
|
if updateStatus == nil {
|
|
continue
|
|
}
|
|
|
|
// Only include containers with updates available or errors.
|
|
if !updateStatus.UpdateAvailable && updateStatus.Error == "" {
|
|
continue
|
|
}
|
|
|
|
containerID := strings.TrimSpace(container.ContainerID())
|
|
if containerID == "" {
|
|
containerID = strings.TrimSpace(container.ID())
|
|
}
|
|
|
|
update := ContainerUpdateInfo{
|
|
TargetID: targetID,
|
|
HostName: hostLabels[targetID],
|
|
ContainerID: containerID,
|
|
ContainerName: trimContainerName(container.Name()),
|
|
Image: container.Image(),
|
|
UpdateAvailable: updateStatus.UpdateAvailable,
|
|
}
|
|
|
|
if updateStatus.CurrentDigest != "" {
|
|
update.CurrentDigest = updateStatus.CurrentDigest
|
|
}
|
|
if updateStatus.LatestDigest != "" {
|
|
update.LatestDigest = updateStatus.LatestDigest
|
|
}
|
|
if !updateStatus.LastChecked.IsZero() {
|
|
update.LastChecked = updateStatus.LastChecked.Unix()
|
|
}
|
|
if updateStatus.Error != "" {
|
|
update.Error = updateStatus.Error
|
|
}
|
|
if update.HostName == "" {
|
|
update.HostName = targetID
|
|
}
|
|
|
|
updates = append(updates, update)
|
|
}
|
|
|
|
return updates
|
|
}
|
|
|
|
// TriggerUpdateCheck implements mcp.UpdatesProvider
|
|
func (a *UpdatesMCPAdapter) TriggerUpdateCheck(hostID string) (DockerCommandStatus, error) {
|
|
if a.commands == nil {
|
|
return DockerCommandStatus{}, fmt.Errorf("monitor not available")
|
|
}
|
|
|
|
cmdStatus, err := a.commands.QueueDockerCheckUpdatesCommand(hostID)
|
|
if err != nil {
|
|
return DockerCommandStatus{}, err
|
|
}
|
|
|
|
return DockerCommandStatus{
|
|
ID: cmdStatus.ID,
|
|
Type: cmdStatus.Type,
|
|
Status: cmdStatus.Status,
|
|
Message: cmdStatus.Message,
|
|
}, nil
|
|
}
|
|
|
|
// UpdateContainer implements mcp.UpdatesProvider
|
|
func (a *UpdatesMCPAdapter) UpdateContainer(hostID, containerID, containerName string) (DockerCommandStatus, error) {
|
|
if a.commands == nil {
|
|
return DockerCommandStatus{}, fmt.Errorf("monitor not available")
|
|
}
|
|
|
|
cmdStatus, err := a.commands.QueueDockerContainerUpdateCommand(hostID, containerID, containerName)
|
|
if err != nil {
|
|
return DockerCommandStatus{}, err
|
|
}
|
|
|
|
return DockerCommandStatus{
|
|
ID: cmdStatus.ID,
|
|
Type: cmdStatus.Type,
|
|
Status: cmdStatus.Status,
|
|
Message: cmdStatus.Message,
|
|
}, nil
|
|
}
|
|
|
|
// IsUpdateActionsEnabled implements mcp.UpdatesProvider
|
|
func (a *UpdatesMCPAdapter) IsUpdateActionsEnabled() bool {
|
|
if a.config == nil {
|
|
return true // Default to enabled if no config
|
|
}
|
|
return a.config.IsDockerUpdateActionsEnabled()
|
|
}
|
|
|
|
// trimContainerName removes the leading slash from container names
|
|
func trimContainerName(name string) string {
|
|
if len(name) > 0 && name[0] == '/' {
|
|
return name[1:]
|
|
}
|
|
return name
|
|
}
|
|
|
|
// ========== Discovery Provider Adapter ==========
|
|
|
|
// DiscoverySource provides access to AI-powered infrastructure discovery data
|
|
type DiscoverySource interface {
|
|
GetDiscovery(id string) (DiscoverySourceData, error)
|
|
GetDiscoveryByResource(resourceType, targetID, resourceID string) (DiscoverySourceData, error)
|
|
ListDiscoveries() ([]DiscoverySourceData, error)
|
|
ListDiscoveriesByType(resourceType string) ([]DiscoverySourceData, error)
|
|
ListDiscoveriesByTarget(targetID string) ([]DiscoverySourceData, error)
|
|
FormatForAIContext(discoveries []DiscoverySourceData) string
|
|
// TriggerDiscovery initiates discovery for a resource, returning discovered data
|
|
TriggerDiscovery(ctx context.Context, resourceType, targetID, resourceID string) (DiscoverySourceData, error)
|
|
}
|
|
|
|
// DiscoverySourceData represents discovery data from the source
|
|
type DiscoverySourceData struct {
|
|
ID string
|
|
ResourceType string
|
|
ResourceID string
|
|
TargetID string
|
|
AgentID string
|
|
Hostname string
|
|
ServiceType string
|
|
ServiceName string
|
|
ServiceVersion string
|
|
Category string
|
|
CLIAccess string
|
|
Facts []DiscoverySourceFact
|
|
ConfigPaths []string
|
|
DataPaths []string
|
|
LogPaths []string
|
|
Ports []DiscoverySourcePort
|
|
DockerMounts []DiscoverySourceDockerMount // Docker bind mounts (for LXCs/VMs running Docker)
|
|
UserNotes string
|
|
Confidence float64
|
|
AIReasoning string
|
|
DiscoveredAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// DiscoverySourceDockerMount represents a Docker bind mount from the source
|
|
type DiscoverySourceDockerMount struct {
|
|
ContainerName string // Docker container name
|
|
Source string // Host path (where to actually write files)
|
|
Destination string // Container path (what the service sees)
|
|
Type string // Mount type: bind, volume, tmpfs
|
|
ReadOnly bool // Whether mount is read-only
|
|
}
|
|
|
|
// DiscoverySourcePort represents a port from the source
|
|
type DiscoverySourcePort struct {
|
|
Port int
|
|
Protocol string
|
|
Process string
|
|
Address string
|
|
}
|
|
|
|
// DiscoverySourceFact represents a fact from the source
|
|
type DiscoverySourceFact struct {
|
|
Category string
|
|
Key string
|
|
Value string
|
|
Source string
|
|
Confidence float64 // 0-1 confidence for this fact
|
|
}
|
|
|
|
// DiscoveryMCPAdapter adapts servicediscovery.Service to MCP DiscoveryProvider interface
|
|
type DiscoveryMCPAdapter struct {
|
|
source DiscoverySource
|
|
}
|
|
|
|
// NewDiscoveryMCPAdapter creates a new adapter for discovery data
|
|
func NewDiscoveryMCPAdapter(source DiscoverySource) *DiscoveryMCPAdapter {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
return &DiscoveryMCPAdapter{source: source}
|
|
}
|
|
|
|
// GetDiscovery implements tools.DiscoveryProvider
|
|
func (a *DiscoveryMCPAdapter) GetDiscovery(id string) (*ResourceDiscoveryInfo, error) {
|
|
if a.source == nil {
|
|
return nil, fmt.Errorf("discovery source not available")
|
|
}
|
|
|
|
data, err := a.source.GetDiscovery(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return a.convertToInfo(data), nil
|
|
}
|
|
|
|
// GetDiscoveryByResource implements tools.DiscoveryProvider
|
|
func (a *DiscoveryMCPAdapter) GetDiscoveryByResource(resourceType, targetID, resourceID string) (*ResourceDiscoveryInfo, error) {
|
|
if a.source == nil {
|
|
return nil, fmt.Errorf("discovery source not available")
|
|
}
|
|
|
|
data, err := a.source.GetDiscoveryByResource(resourceType, targetID, resourceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return a.convertToInfo(data), nil
|
|
}
|
|
|
|
// ListDiscoveries implements tools.DiscoveryProvider
|
|
func (a *DiscoveryMCPAdapter) ListDiscoveries() ([]*ResourceDiscoveryInfo, error) {
|
|
if a.source == nil {
|
|
return nil, fmt.Errorf("discovery source not available")
|
|
}
|
|
|
|
dataList, err := a.source.ListDiscoveries()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return a.convertList(dataList), nil
|
|
}
|
|
|
|
// ListDiscoveriesByType implements tools.DiscoveryProvider
|
|
func (a *DiscoveryMCPAdapter) ListDiscoveriesByType(resourceType string) ([]*ResourceDiscoveryInfo, error) {
|
|
if a.source == nil {
|
|
return nil, fmt.Errorf("discovery source not available")
|
|
}
|
|
|
|
dataList, err := a.source.ListDiscoveriesByType(resourceType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return a.convertList(dataList), nil
|
|
}
|
|
|
|
// ListDiscoveriesByTarget implements tools.DiscoveryProvider
|
|
func (a *DiscoveryMCPAdapter) ListDiscoveriesByTarget(targetID string) ([]*ResourceDiscoveryInfo, error) {
|
|
if a.source == nil {
|
|
return nil, fmt.Errorf("discovery source not available")
|
|
}
|
|
|
|
dataList, err := a.source.ListDiscoveriesByTarget(targetID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return a.convertList(dataList), nil
|
|
}
|
|
|
|
// FormatForAIContext implements tools.DiscoveryProvider
|
|
func (a *DiscoveryMCPAdapter) FormatForAIContext(discoveries []*ResourceDiscoveryInfo) string {
|
|
if a.source == nil {
|
|
return ""
|
|
}
|
|
|
|
// Convert back to source data format
|
|
sourceData := make([]DiscoverySourceData, 0, len(discoveries))
|
|
for _, d := range discoveries {
|
|
if d == nil {
|
|
continue
|
|
}
|
|
facts := make([]DiscoverySourceFact, 0, len(d.Facts))
|
|
for _, f := range d.Facts {
|
|
facts = append(facts, DiscoverySourceFact{
|
|
Category: f.Category,
|
|
Key: f.Key,
|
|
Value: f.Value,
|
|
Source: f.Source,
|
|
Confidence: f.Confidence,
|
|
})
|
|
}
|
|
ports := make([]DiscoverySourcePort, 0, len(d.Ports))
|
|
for _, p := range d.Ports {
|
|
ports = append(ports, DiscoverySourcePort{
|
|
Port: p.Port,
|
|
Protocol: p.Protocol,
|
|
Process: p.Process,
|
|
Address: p.Address,
|
|
})
|
|
}
|
|
dockerMounts := make([]DiscoverySourceDockerMount, 0, len(d.BindMounts))
|
|
for _, m := range d.BindMounts {
|
|
dockerMounts = append(dockerMounts, DiscoverySourceDockerMount{
|
|
ContainerName: m.ContainerName,
|
|
Source: m.Source,
|
|
Destination: m.Destination,
|
|
Type: m.Type,
|
|
ReadOnly: m.ReadOnly,
|
|
})
|
|
}
|
|
targetID := strings.TrimSpace(d.TargetID)
|
|
sourceData = append(sourceData, DiscoverySourceData{
|
|
ID: d.ID,
|
|
ResourceType: d.ResourceType,
|
|
ResourceID: d.ResourceID,
|
|
TargetID: targetID,
|
|
AgentID: d.AgentID,
|
|
Hostname: d.Hostname,
|
|
ServiceType: d.ServiceType,
|
|
ServiceName: d.ServiceName,
|
|
ServiceVersion: d.ServiceVersion,
|
|
Category: d.Category,
|
|
CLIAccess: d.CLIAccess,
|
|
Facts: facts,
|
|
ConfigPaths: d.ConfigPaths,
|
|
DataPaths: d.DataPaths,
|
|
Ports: ports,
|
|
DockerMounts: dockerMounts,
|
|
UserNotes: d.UserNotes,
|
|
Confidence: d.Confidence,
|
|
AIReasoning: d.AIReasoning,
|
|
DiscoveredAt: d.DiscoveredAt,
|
|
UpdatedAt: d.UpdatedAt,
|
|
})
|
|
}
|
|
|
|
return a.source.FormatForAIContext(sourceData)
|
|
}
|
|
|
|
// TriggerDiscovery implements tools.DiscoveryProvider
|
|
func (a *DiscoveryMCPAdapter) TriggerDiscovery(ctx context.Context, resourceType, targetID, resourceID string) (*ResourceDiscoveryInfo, error) {
|
|
if a.source == nil {
|
|
return nil, fmt.Errorf("discovery source not available")
|
|
}
|
|
|
|
data, err := a.source.TriggerDiscovery(ctx, resourceType, targetID, resourceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return a.convertToInfo(data), nil
|
|
}
|
|
|
|
func (a *DiscoveryMCPAdapter) convertToInfo(data DiscoverySourceData) *ResourceDiscoveryInfo {
|
|
if data.ID == "" {
|
|
return nil
|
|
}
|
|
|
|
facts := make([]DiscoveryFact, 0, len(data.Facts))
|
|
for _, f := range data.Facts {
|
|
facts = append(facts, DiscoveryFact{
|
|
Category: f.Category,
|
|
Key: f.Key,
|
|
Value: f.Value,
|
|
Source: f.Source,
|
|
Confidence: f.Confidence,
|
|
})
|
|
}
|
|
|
|
ports := make([]DiscoveryPortInfo, 0, len(data.Ports))
|
|
for _, p := range data.Ports {
|
|
ports = append(ports, DiscoveryPortInfo{
|
|
Port: p.Port,
|
|
Protocol: p.Protocol,
|
|
Process: p.Process,
|
|
Address: p.Address,
|
|
})
|
|
}
|
|
|
|
// Convert DockerMounts to BindMounts
|
|
bindMounts := make([]DiscoveryMount, 0, len(data.DockerMounts))
|
|
for _, m := range data.DockerMounts {
|
|
bindMounts = append(bindMounts, DiscoveryMount{
|
|
ContainerName: m.ContainerName,
|
|
Source: m.Source,
|
|
Destination: m.Destination,
|
|
Type: m.Type,
|
|
ReadOnly: m.ReadOnly,
|
|
})
|
|
}
|
|
|
|
targetID := strings.TrimSpace(data.TargetID)
|
|
agentID := strings.TrimSpace(data.AgentID)
|
|
if agentID == "" && data.ResourceType == "agent" {
|
|
agentID = targetID
|
|
}
|
|
|
|
return &ResourceDiscoveryInfo{
|
|
ID: data.ID,
|
|
ResourceType: data.ResourceType,
|
|
ResourceID: data.ResourceID,
|
|
TargetID: targetID,
|
|
AgentID: agentID,
|
|
Hostname: data.Hostname,
|
|
ServiceType: data.ServiceType,
|
|
ServiceName: data.ServiceName,
|
|
ServiceVersion: data.ServiceVersion,
|
|
Category: data.Category,
|
|
CLIAccess: data.CLIAccess,
|
|
Facts: facts,
|
|
ConfigPaths: data.ConfigPaths,
|
|
DataPaths: data.DataPaths,
|
|
LogPaths: data.LogPaths,
|
|
Ports: ports,
|
|
BindMounts: bindMounts,
|
|
UserNotes: data.UserNotes,
|
|
Confidence: data.Confidence,
|
|
AIReasoning: data.AIReasoning,
|
|
DiscoveredAt: data.DiscoveredAt,
|
|
UpdatedAt: data.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func (a *DiscoveryMCPAdapter) convertList(dataList []DiscoverySourceData) []*ResourceDiscoveryInfo {
|
|
result := make([]*ResourceDiscoveryInfo, 0, len(dataList))
|
|
for _, data := range dataList {
|
|
if info := a.convertToInfo(data); info != nil {
|
|
result = append(result, info)
|
|
}
|
|
}
|
|
return result
|
|
}
|