package cluster import ( "context" "fmt" "sort" "strings" "time" ) type DriftIssue struct { Level string `json:"level"` Kind string `json:"kind"` Message string `json:"message"` } type DriftReport struct { Cluster string `json:"cluster"` Generated time.Time `json:"generated_at"` Issues []DriftIssue `json:"issues,omitempty"` } func (s *Service) DetectDrift(ctx context.Context, selector string) (DriftReport, error) { c, err := s.Get(selector) if err != nil { return DriftReport{}, err } rep := DriftReport{Cluster: c.Name, Generated: s.now().UTC()} expected := strings.TrimSpace(s.ExpectedAgentVersion()) dctrl := normalizeDriftControl(c.Drift) if !c.Agent.Installed { rep.Issues = append(rep.Issues, DriftIssue{Level: "warn", Kind: "agent", Message: "agent is not installed"}) return rep, nil } pingCtx, cancel := context.WithTimeout(ctx, 2500*time.Millisecond) ping, pingErr := s.PingAgent(pingCtx, c.ID) cancel() if pingErr != nil || !ping.Reachable || ping.StatusCode >= 400 { rep.Issues = append(rep.Issues, DriftIssue{Level: "crit", Kind: "agent", Message: "agent is unreachable"}) } else { nodeVersion := strings.TrimSpace(ping.Version) if nodeVersion == "" { nodeVersion = strings.TrimSpace(c.Agent.Version) } if expected != "" && nodeVersion != "" && nodeVersion != expected { rep.Issues = append(rep.Issues, DriftIssue{Level: "warn", Kind: "agent_version", Message: fmt.Sprintf("agent version mismatch: node=%s local=%s", nodeVersion, expected)}) } if nodeVersion != "" { if ok, latest, known := s.CompareAgentVersion(nodeVersion); known && !ok { rep.Issues = append(rep.Issues, DriftIssue{Level: "warn", Kind: "agent_outdated", Message: fmt.Sprintf("node version %s is older than latest known %s", nodeVersion, latest)}) } if dctrl.Baseline.Enabled && strings.TrimSpace(dctrl.Baseline.AgentVersion) != "" && !strings.EqualFold(strings.TrimSpace(dctrl.Baseline.AgentVersion), nodeVersion) { rep.Issues = append(rep.Issues, DriftIssue{ Level: "warn", Kind: "baseline_agent_version", Message: fmt.Sprintf("baseline agent version mismatch: baseline=%s live=%s", dctrl.Baseline.AgentVersion, nodeVersion), }) } } } softCtx, softCancel := context.WithTimeout(ctx, 7*time.Second) fresh, softErr := s.probeSoftware(softCtx, c, "", "") softCancel() if softErr != nil { rep.Issues = append(rep.Issues, DriftIssue{Level: "warn", Kind: "software_probe", Message: softErr.Error()}) } else { oldSet := strings.TrimSpace(c.Software.Summary()) newSet := strings.TrimSpace(fresh.Summary()) if oldSet != "" && oldSet != "-" && newSet != oldSet { rep.Issues = append(rep.Issues, DriftIssue{Level: "warn", Kind: "software", Message: fmt.Sprintf("software support changed: stored=%s live=%s", oldSet, newSet)}) } if dctrl.Baseline.Enabled && strings.TrimSpace(dctrl.Baseline.Software) != "" && !strings.EqualFold(strings.TrimSpace(dctrl.Baseline.Software), newSet) { rep.Issues = append(rep.Issues, DriftIssue{ Level: "warn", Kind: "baseline_software", Message: fmt.Sprintf("baseline software mismatch: baseline=%s live=%s", dctrl.Baseline.Software, newSet), }) } } sort.Slice(rep.Issues, func(i, j int) bool { return rep.Issues[i].Kind < rep.Issues[j].Kind }) return rep, nil }