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:
2026-07-19 20:01:11 +02:00
parent 50568127c5
commit 32af91fd30
22 changed files with 4337 additions and 32 deletions
@@ -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
}