gomcp/internal/application/tools/doctor.go

257 lines
8.4 KiB
Go

package tools
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
// DoctorCheck represents a single diagnostic check result.
type DoctorCheck struct {
Name string `json:"name"`
Status string `json:"status"` // "OK", "WARN", "FAIL"
Details string `json:"details,omitempty"`
Elapsed string `json:"elapsed"`
}
// DoctorReport is the full self-diagnostic report (v3.7).
type DoctorReport struct {
Timestamp time.Time `json:"timestamp"`
Checks []DoctorCheck `json:"checks"`
Summary string `json:"summary"` // "HEALTHY", "DEGRADED", "CRITICAL"
}
// DoctorService provides self-diagnostic capabilities (v3.7 Cerebro).
type DoctorService struct {
db *sql.DB
rlmDir string
facts *FactService
embedderName string // v3.7: Oracle model name
socChecker SOCHealthChecker // v3.9: SOC health
}
// SOCHealthChecker is an interface for SOC health diagnostics.
// Implemented by application/soc.Service to avoid circular imports.
type SOCHealthChecker interface {
Dashboard() (SOCDashboardData, error)
}
// SOCDashboardData mirrors the dashboard KPIs needed for doctor checks.
type SOCDashboardData struct {
TotalEvents int `json:"total_events"`
CorrelationRules int `json:"correlation_rules"`
Playbooks int `json:"playbooks"`
ChainValid bool `json:"chain_valid"`
SensorsOnline int `json:"sensors_online"`
SensorsTotal int `json:"sensors_total"`
}
// NewDoctorService creates the doctor diagnostic service.
func NewDoctorService(db *sql.DB, rlmDir string, facts *FactService) *DoctorService {
return &DoctorService{db: db, rlmDir: rlmDir, facts: facts}
}
// SetEmbedderName sets the Oracle model name for diagnostics.
func (d *DoctorService) SetEmbedderName(name string) {
d.embedderName = name
}
// SetSOCChecker sets the SOC health checker for diagnostics (v3.9).
func (d *DoctorService) SetSOCChecker(c SOCHealthChecker) {
d.socChecker = c
}
// RunDiagnostics performs all self-diagnostic checks.
func (d *DoctorService) RunDiagnostics(ctx context.Context) DoctorReport {
report := DoctorReport{
Timestamp: time.Now(),
}
report.Checks = append(report.Checks, d.checkStorage())
report.Checks = append(report.Checks, d.checkGenome(ctx))
report.Checks = append(report.Checks, d.checkLeash())
report.Checks = append(report.Checks, d.checkOracle())
report.Checks = append(report.Checks, d.checkPermissions())
report.Checks = append(report.Checks, d.checkDecisionsLog())
report.Checks = append(report.Checks, d.checkSOC())
// Compute summary.
fails, warns := 0, 0
for _, c := range report.Checks {
switch c.Status {
case "FAIL":
fails++
case "WARN":
warns++
}
}
switch {
case fails > 0:
report.Summary = "CRITICAL"
case warns > 0:
report.Summary = "DEGRADED"
default:
report.Summary = "HEALTHY"
}
return report
}
func (d *DoctorService) checkStorage() DoctorCheck {
start := time.Now()
if d.db == nil {
return DoctorCheck{Name: "Storage", Status: "FAIL", Details: "database not configured", Elapsed: since(start)}
}
var result string
err := d.db.QueryRow("PRAGMA integrity_check").Scan(&result)
if err != nil {
return DoctorCheck{Name: "Storage", Status: "FAIL", Details: err.Error(), Elapsed: since(start)}
}
if result != "ok" {
return DoctorCheck{Name: "Storage", Status: "FAIL", Details: "integrity: " + result, Elapsed: since(start)}
}
return DoctorCheck{Name: "Storage", Status: "OK", Details: "PRAGMA integrity_check = ok", Elapsed: since(start)}
}
func (d *DoctorService) checkGenome(ctx context.Context) DoctorCheck {
start := time.Now()
if d.facts == nil {
return DoctorCheck{Name: "Genome", Status: "WARN", Details: "fact service not configured", Elapsed: since(start)}
}
hash, count, err := d.facts.VerifyGenome(ctx)
if err != nil {
return DoctorCheck{Name: "Genome", Status: "FAIL", Details: err.Error(), Elapsed: since(start)}
}
if count == 0 {
return DoctorCheck{Name: "Genome", Status: "WARN", Details: "no genes found", Elapsed: since(start)}
}
return DoctorCheck{Name: "Genome", Status: "OK", Details: fmt.Sprintf("%d genes, hash=%s", count, hash[:16]), Elapsed: since(start)}
}
func (d *DoctorService) checkLeash() DoctorCheck {
start := time.Now()
leashPath := filepath.Join(d.rlmDir, "..", ".sentinel_leash")
data, err := os.ReadFile(leashPath)
if err != nil {
if os.IsNotExist(err) {
return DoctorCheck{Name: "Leash", Status: "OK", Details: "mode=ARMED (no leash file)", Elapsed: since(start)}
}
return DoctorCheck{Name: "Leash", Status: "WARN", Details: "cannot read: " + err.Error(), Elapsed: since(start)}
}
content := string(data)
switch {
case contains(content, "ZERO-G"):
return DoctorCheck{Name: "Leash", Status: "WARN", Details: "mode=ZERO-G (ethical filters disabled)", Elapsed: since(start)}
case contains(content, "SAFE"):
return DoctorCheck{Name: "Leash", Status: "OK", Details: "mode=SAFE (read-only)", Elapsed: since(start)}
case contains(content, "ARMED"):
return DoctorCheck{Name: "Leash", Status: "OK", Details: "mode=ARMED", Elapsed: since(start)}
default:
return DoctorCheck{Name: "Leash", Status: "WARN", Details: "unknown mode: " + content[:min(20, len(content))], Elapsed: since(start)}
}
}
func (d *DoctorService) checkPermissions() DoctorCheck {
start := time.Now()
testFile := filepath.Join(d.rlmDir, ".doctor_probe")
err := os.WriteFile(testFile, []byte("probe"), 0o644)
if err != nil {
return DoctorCheck{Name: "Permissions", Status: "FAIL", Details: "cannot write to .rlm/: " + err.Error(), Elapsed: since(start)}
}
os.Remove(testFile)
return DoctorCheck{Name: "Permissions", Status: "OK", Details: ".rlm/ writable", Elapsed: since(start)}
}
func (d *DoctorService) checkDecisionsLog() DoctorCheck {
start := time.Now()
logPath := filepath.Join(d.rlmDir, "decisions.log")
if _, err := os.Stat(logPath); os.IsNotExist(err) {
return DoctorCheck{Name: "Decisions", Status: "WARN", Details: "decisions.log not found (no decisions recorded yet)", Elapsed: since(start)}
}
info, err := os.Stat(logPath)
if err != nil {
return DoctorCheck{Name: "Decisions", Status: "FAIL", Details: err.Error(), Elapsed: since(start)}
}
return DoctorCheck{Name: "Decisions", Status: "OK", Details: fmt.Sprintf("decisions.log size=%d bytes", info.Size()), Elapsed: since(start)}
}
func (d *DoctorService) checkOracle() DoctorCheck {
start := time.Now()
if d.embedderName == "" {
return DoctorCheck{Name: "Oracle", Status: "WARN", Details: "no embedder configured (FTS5 fallback)", Elapsed: since(start)}
}
if contains(d.embedderName, "onnx") || contains(d.embedderName, "ONNX") {
return DoctorCheck{Name: "Oracle", Status: "OK", Details: "ONNX model loaded: " + d.embedderName, Elapsed: since(start)}
}
return DoctorCheck{Name: "Oracle", Status: "OK", Details: "embedder: " + d.embedderName, Elapsed: since(start)}
}
func (d *DoctorService) checkSOC() DoctorCheck {
start := time.Now()
if d.socChecker == nil {
return DoctorCheck{Name: "SOC", Status: "WARN", Details: "SOC service not configured", Elapsed: since(start)}
}
dash, err := d.socChecker.Dashboard()
if err != nil {
return DoctorCheck{Name: "SOC", Status: "FAIL", Details: "dashboard error: " + err.Error(), Elapsed: since(start)}
}
// Check chain integrity.
if !dash.ChainValid {
return DoctorCheck{
Name: "SOC",
Status: "WARN",
Details: fmt.Sprintf("chain BROKEN (rules=%d, playbooks=%d, events=%d)", dash.CorrelationRules, dash.Playbooks, dash.TotalEvents),
Elapsed: since(start),
}
}
// Check sensor health.
offline := dash.SensorsTotal - dash.SensorsOnline
if offline > 0 {
return DoctorCheck{
Name: "SOC",
Status: "WARN",
Details: fmt.Sprintf("rules=%d, playbooks=%d, events=%d, %d/%d sensors OFFLINE", dash.CorrelationRules, dash.Playbooks, dash.TotalEvents, offline, dash.SensorsTotal),
Elapsed: since(start),
}
}
return DoctorCheck{
Name: "SOC",
Status: "OK",
Details: fmt.Sprintf("rules=%d, playbooks=%d, events=%d, chain=valid", dash.CorrelationRules, dash.Playbooks, dash.TotalEvents),
Elapsed: since(start),
}
}
func since(t time.Time) string {
return fmt.Sprintf("%dms", time.Since(t).Milliseconds())
}
func contains(s, substr string) bool {
for i := 0; i+len(substr) <= len(s); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// ToJSON is already in the package. Alias for DoctorReport.
func (r DoctorReport) JSON() string {
data, _ := json.MarshalIndent(r, "", " ")
return string(data)
}