Let operators manage ordered action groups on each host, run them manually or on a schedule over SSH, and inspect disk-backed JSON run logs from a node detail UI.
228 lines
5.8 KiB
Go
228 lines
5.8 KiB
Go
package settings
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var diskSafeFilenamePattern = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
|
|
|
|
// LoadNodeActionGroups reads and parses node-action-groups.json from dir.
|
|
func LoadNodeActionGroups(dir string) (NodeActionGroupStore, error) {
|
|
path := filepath.Join(dir, NodeActionGroupsFileName)
|
|
payload, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return NodeActionGroupStore{}, err
|
|
}
|
|
|
|
var store NodeActionGroupStore
|
|
if err := json.Unmarshal(payload, &store); err != nil {
|
|
return NodeActionGroupStore{}, fmt.Errorf("parse node action groups: %w", err)
|
|
}
|
|
normalizeNodeActionGroupStore(&store)
|
|
return store, nil
|
|
}
|
|
|
|
// LoadNodeActionGroupsOrEmpty returns an empty store when the file is missing.
|
|
func LoadNodeActionGroupsOrEmpty(dir string) (NodeActionGroupStore, error) {
|
|
store, err := LoadNodeActionGroups(dir)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return NodeActionGroupStore{Groups: []NodeActionGroup{}}, nil
|
|
}
|
|
return NodeActionGroupStore{}, err
|
|
}
|
|
return store, nil
|
|
}
|
|
|
|
// SaveNodeActionGroups writes node-action-groups.json to dir with mode 0640.
|
|
func SaveNodeActionGroups(dir string, store NodeActionGroupStore) error {
|
|
if err := ensureConfigDir(dir); err != nil {
|
|
return err
|
|
}
|
|
normalizeNodeActionGroupStore(&store)
|
|
|
|
payload, err := json.MarshalIndent(store, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encode node action groups: %w", err)
|
|
}
|
|
payload = append(payload, '\n')
|
|
|
|
path := filepath.Join(dir, NodeActionGroupsFileName)
|
|
if err := os.WriteFile(path, payload, 0o640); err != nil {
|
|
return fmt.Errorf("write node action groups: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeNodeActionGroupStore(store *NodeActionGroupStore) {
|
|
if store.Groups == nil {
|
|
store.Groups = []NodeActionGroup{}
|
|
}
|
|
for index := range store.Groups {
|
|
if store.Groups[index].Items == nil {
|
|
store.Groups[index].Items = []NodeActionItem{}
|
|
}
|
|
if store.Groups[index].Schedule.Times == nil {
|
|
store.Groups[index].Schedule.Times = []ActionGroupTimeSpec{}
|
|
}
|
|
for itemIndex := range store.Groups[index].Items {
|
|
if store.Groups[index].Items[itemIndex].Env == nil {
|
|
store.Groups[index].Items[itemIndex].Env = []ActionEnvVar{}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// DiskSafeFilename converts a group name into a safe filename stem.
|
|
func DiskSafeFilename(name string) string {
|
|
trimmed := strings.TrimSpace(name)
|
|
if trimmed == "" {
|
|
return "unnamed"
|
|
}
|
|
safe := diskSafeFilenamePattern.ReplaceAllString(trimmed, "_")
|
|
safe = strings.Trim(safe, "._-")
|
|
if safe == "" {
|
|
return "unnamed"
|
|
}
|
|
if len(safe) > 120 {
|
|
safe = safe[:120]
|
|
}
|
|
return safe
|
|
}
|
|
|
|
// FindNodeActionGroupIndex returns the index of a group by id, or -1.
|
|
func FindNodeActionGroupIndex(groups []NodeActionGroup, groupID string) int {
|
|
for index := range groups {
|
|
if groups[index].ID == groupID {
|
|
return index
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// FindNodeActionItemIndex returns the index of an item by id, or -1.
|
|
func FindNodeActionItemIndex(items []NodeActionItem, itemID string) int {
|
|
for index := range items {
|
|
if items[index].ID == itemID {
|
|
return index
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// GroupsForNode returns groups belonging to nodeID.
|
|
func GroupsForNode(store NodeActionGroupStore, nodeID string) []NodeActionGroup {
|
|
result := make([]NodeActionGroup, 0)
|
|
for _, group := range store.Groups {
|
|
if group.NodeID == nodeID {
|
|
result = append(result, group)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// UpdateGroupLastRunAt sets LastRunAt and UpdatedAt for a group and saves.
|
|
func UpdateGroupLastRunAt(dir string, groupID string, at time.Time) error {
|
|
store, err := LoadNodeActionGroupsOrEmpty(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
index := FindNodeActionGroupIndex(store.Groups, groupID)
|
|
if index < 0 {
|
|
return fmt.Errorf("action group not found")
|
|
}
|
|
runAt := at.UTC()
|
|
store.Groups[index].LastRunAt = &runAt
|
|
store.Groups[index].UpdatedAt = runAt
|
|
return SaveNodeActionGroups(dir, store)
|
|
}
|
|
|
|
// IsScheduleDue reports whether the group should run at now given its schedule and LastRunAt.
|
|
func IsScheduleDue(group NodeActionGroup, now time.Time) bool {
|
|
schedule := group.Schedule
|
|
if !schedule.Enabled {
|
|
return false
|
|
}
|
|
localNow := now.In(time.Local)
|
|
|
|
switch schedule.Kind {
|
|
case ActionGroupScheduleInterval:
|
|
if schedule.EverySeconds < 1 {
|
|
return false
|
|
}
|
|
if group.LastRunAt == nil {
|
|
return true
|
|
}
|
|
elapsed := localNow.Sub(group.LastRunAt.In(time.Local))
|
|
return elapsed >= time.Duration(schedule.EverySeconds)*time.Second
|
|
case ActionGroupScheduleTimes:
|
|
if len(schedule.Times) == 0 {
|
|
return false
|
|
}
|
|
for _, spec := range schedule.Times {
|
|
if !timeSpecMatches(spec, localNow) {
|
|
continue
|
|
}
|
|
if group.LastRunAt == nil {
|
|
return true
|
|
}
|
|
lastLocal := group.LastRunAt.In(time.Local)
|
|
// Fire once per matching minute window.
|
|
sameMinute := lastLocal.Year() == localNow.Year() &&
|
|
lastLocal.YearDay() == localNow.YearDay() &&
|
|
lastLocal.Hour() == localNow.Hour() &&
|
|
lastLocal.Minute() == localNow.Minute()
|
|
if !sameMinute {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func timeSpecMatches(spec ActionGroupTimeSpec, now time.Time) bool {
|
|
hour, minute, ok := parseHHMM(spec.Time)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if now.Hour() != hour || now.Minute() != minute {
|
|
return false
|
|
}
|
|
if len(spec.DaysOfWeek) == 0 {
|
|
return true
|
|
}
|
|
weekday := int(now.Weekday())
|
|
for _, day := range spec.DaysOfWeek {
|
|
if day == weekday {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func parseHHMM(value string) (hour int, minute int, ok bool) {
|
|
parts := strings.Split(strings.TrimSpace(value), ":")
|
|
if len(parts) != 2 {
|
|
return 0, 0, false
|
|
}
|
|
if _, err := fmt.Sscanf(parts[0], "%d", &hour); err != nil {
|
|
return 0, 0, false
|
|
}
|
|
if _, err := fmt.Sscanf(parts[1], "%d", &minute); err != nil {
|
|
return 0, 0, false
|
|
}
|
|
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
|
|
return 0, 0, false
|
|
}
|
|
return hour, minute, true
|
|
}
|