package cluster import ( "encoding/json" "errors" "os" "path/filepath" "sort" "strings" "time" ) type RunbookStep struct { Title string `json:"title"` Command string `json:"command,omitempty"` Note string `json:"note,omitempty"` } type Runbook struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description,omitempty"` Steps []RunbookStep `json:"steps"` BuiltIn bool `json:"built_in,omitempty"` CreatedAt time.Time `json:"created_at,omitempty"` UpdatedAt time.Time `json:"updated_at,omitempty"` } func builtinRunbooks() []Runbook { return []Runbook{ { ID: "vm-health-check", Name: "VM Health Check", Description: "Quick validation of agent, VM inventory and VM alert policy.", BuiltIn: true, Steps: []RunbookStep{ {Title: "Check agent status", Command: "cluster agent status"}, {Title: "Check cluster drift", Command: "cluster drift"}, {Title: "Check VM states", Command: "cluster alert-vm check"}, {Title: "Inspect VM allocations", Command: "kvm top"}, }, }, { ID: "traffic-billing-audit", Name: "Traffic Billing Audit", Description: "Collect interface P95 and graph evidence for billing period.", BuiltIn: true, Steps: []RunbookStep{ {Title: "List interfaces", Command: "cluster usage --range 1h"}, {Title: "Compute P95 for target iface", Command: "cluster p95 --iface --range 30d --graph"}, {Title: "Export report", Command: "cluster report export --format json --out ./report.json"}, }, }, } } func (s *Service) runbookPath() string { return filepath.Join(s.DataDir(), "runbooks", "custom.json") } func (s *Service) loadCustomRunbooks() ([]Runbook, error) { path := s.runbookPath() raw, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return []Runbook{}, nil } return nil, err } if len(strings.TrimSpace(string(raw))) == 0 { return []Runbook{}, nil } var items []Runbook if err := json.Unmarshal(raw, &items); err != nil { return nil, err } out := make([]Runbook, 0, len(items)) for _, rb := range items { rb.ID = strings.TrimSpace(rb.ID) rb.Name = strings.TrimSpace(rb.Name) if rb.ID == "" || rb.Name == "" { continue } rb.BuiltIn = false out = append(out, rb) } return out, nil } func (s *Service) saveCustomRunbooks(items []Runbook) error { path := s.runbookPath() if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } b, err := json.MarshalIndent(items, "", " ") if err != nil { return err } b = append(b, '\n') tmp := path + ".tmp" if err := os.WriteFile(tmp, b, 0o600); err != nil { return err } return os.Rename(tmp, path) } func (s *Service) ListRunbooks() ([]Runbook, error) { built := builtinRunbooks() custom, err := s.loadCustomRunbooks() if err != nil { return nil, err } all := append(append([]Runbook{}, built...), custom...) sort.Slice(all, func(i, j int) bool { return strings.ToLower(all[i].ID) < strings.ToLower(all[j].ID) }) return all, nil } func (s *Service) GetRunbook(selector string) (Runbook, bool) { selector = strings.TrimSpace(selector) items, err := s.ListRunbooks() if err != nil { return Runbook{}, false } for _, rb := range items { if strings.EqualFold(rb.ID, selector) || strings.EqualFold(rb.Name, selector) { return rb, true } } return Runbook{}, false } func (s *Service) AddRunbook(rb Runbook) (Runbook, error) { rb.ID = strings.TrimSpace(rb.ID) rb.Name = strings.TrimSpace(rb.Name) if rb.ID == "" || rb.Name == "" { return Runbook{}, errors.New("runbook id and name are required") } if len(rb.Steps) == 0 { return Runbook{}, errors.New("runbook steps are required") } if b, ok := s.GetRunbook(rb.ID); ok && b.BuiltIn { return Runbook{}, errors.New("cannot overwrite built-in runbook") } custom, err := s.loadCustomRunbooks() if err != nil { return Runbook{}, err } for _, it := range custom { if strings.EqualFold(it.ID, rb.ID) { return Runbook{}, errors.New("runbook id already exists") } } now := s.now().UTC() rb.BuiltIn = false rb.CreatedAt = now rb.UpdatedAt = now custom = append(custom, rb) if err := s.saveCustomRunbooks(custom); err != nil { return Runbook{}, err } _ = s.AppendChange("runbook.add", rb.ID, rb.Name) return rb, nil } func (s *Service) RemoveRunbook(selector string) (Runbook, error) { custom, err := s.loadCustomRunbooks() if err != nil { return Runbook{}, err } idx := -1 for i, rb := range custom { if strings.EqualFold(rb.ID, selector) || strings.EqualFold(rb.Name, selector) { idx = i break } } if idx < 0 { return Runbook{}, errors.New("custom runbook not found") } removed := custom[idx] custom = append(custom[:idx], custom[idx+1:]...) if err := s.saveCustomRunbooks(custom); err != nil { return Runbook{}, err } _ = s.AppendChange("runbook.remove", removed.ID, removed.Name) return removed, nil }