188 lines
4.9 KiB
Go
188 lines
4.9 KiB
Go
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 <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
|
|
}
|