Add per-node action groups with schedules, runs, and logs.
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.
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user