Files
2026-06-16 21:52:10 +04:00

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
}