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,174 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaxActionLogRuns is the retention cap per action-group log file.
|
||||
const MaxActionLogRuns = 200
|
||||
|
||||
// ActionLogDir returns action-logs/<nodeID> under the config dir.
|
||||
func ActionLogDir(configDir string, nodeID string) string {
|
||||
return filepath.Join(configDir, ActionLogsDirName, nodeID)
|
||||
}
|
||||
|
||||
// ActionLogFilePath returns the JSON log path for a node + group name.
|
||||
func ActionLogFilePath(configDir string, nodeID string, groupName string) string {
|
||||
filename := DiskSafeFilename(groupName) + ".json"
|
||||
return filepath.Join(ActionLogDir(configDir, nodeID), filename)
|
||||
}
|
||||
|
||||
// AppendActionLogRun appends a run record to the group's log file and trims retention.
|
||||
func AppendActionLogRun(configDir string, record ActionGroupRunRecord) error {
|
||||
if strings.TrimSpace(record.NodeID) == "" {
|
||||
return fmt.Errorf("node id is required")
|
||||
}
|
||||
if strings.TrimSpace(record.GroupName) == "" {
|
||||
return fmt.Errorf("group name is required")
|
||||
}
|
||||
|
||||
dir := ActionLogDir(configDir, record.NodeID)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return fmt.Errorf("create action log dir: %w", err)
|
||||
}
|
||||
|
||||
path := ActionLogFilePath(configDir, record.NodeID, record.GroupName)
|
||||
file, err := LoadActionLogFileOrEmpty(path, record.NodeID, record.GroupID, record.GroupName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
file.NodeID = record.NodeID
|
||||
file.GroupID = record.GroupID
|
||||
file.GroupName = record.GroupName
|
||||
file.Runs = append(file.Runs, record)
|
||||
if len(file.Runs) > MaxActionLogRuns {
|
||||
file.Runs = file.Runs[len(file.Runs)-MaxActionLogRuns:]
|
||||
}
|
||||
|
||||
return SaveActionLogFile(path, file)
|
||||
}
|
||||
|
||||
// LoadActionLogFile reads a log JSON file from path.
|
||||
func LoadActionLogFile(path string) (ActionLogFile, error) {
|
||||
payload, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ActionLogFile{}, err
|
||||
}
|
||||
var file ActionLogFile
|
||||
if err := json.Unmarshal(payload, &file); err != nil {
|
||||
return ActionLogFile{}, fmt.Errorf("parse action log: %w", err)
|
||||
}
|
||||
if file.Runs == nil {
|
||||
file.Runs = []ActionGroupRunRecord{}
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// LoadActionLogFileOrEmpty returns an empty file when missing.
|
||||
func LoadActionLogFileOrEmpty(
|
||||
path string,
|
||||
nodeID string,
|
||||
groupID string,
|
||||
groupName string,
|
||||
) (ActionLogFile, error) {
|
||||
file, err := LoadActionLogFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return ActionLogFile{
|
||||
NodeID: nodeID,
|
||||
GroupID: groupID,
|
||||
GroupName: groupName,
|
||||
Runs: []ActionGroupRunRecord{},
|
||||
}, nil
|
||||
}
|
||||
return ActionLogFile{}, err
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// SaveActionLogFile writes a log JSON file with mode 0640.
|
||||
func SaveActionLogFile(path string, file ActionLogFile) error {
|
||||
if file.Runs == nil {
|
||||
file.Runs = []ActionGroupRunRecord{}
|
||||
}
|
||||
payload, err := json.MarshalIndent(file, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode action log: %w", err)
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
if err := os.WriteFile(path, payload, 0o640); err != nil {
|
||||
return fmt.Errorf("write action log: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListActionLogFiles lists JSON log files for a node.
|
||||
func ListActionLogFiles(configDir string, nodeID string) ([]ActionLogFileInfo, error) {
|
||||
dir := ActionLogDir(configDir, nodeID)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []ActionLogFileInfo{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("list action logs: %w", err)
|
||||
}
|
||||
|
||||
result := make([]ActionLogFileInfo, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !strings.HasSuffix(name, ".json") {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
groupName := strings.TrimSuffix(name, ".json")
|
||||
groupID := ""
|
||||
file, loadErr := LoadActionLogFile(filepath.Join(dir, name))
|
||||
if loadErr == nil {
|
||||
if file.GroupName != "" {
|
||||
groupName = file.GroupName
|
||||
}
|
||||
groupID = file.GroupID
|
||||
}
|
||||
result = append(result, ActionLogFileInfo{
|
||||
Filename: name,
|
||||
GroupName: groupName,
|
||||
GroupID: groupID,
|
||||
ModTime: info.ModTime().UTC(),
|
||||
SizeBytes: info.Size(),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ReadActionLogFileByName reads one log file for a node after validating the name.
|
||||
func ReadActionLogFileByName(configDir string, nodeID string, filename string) (ActionLogFile, error) {
|
||||
safeName := filepath.Base(strings.TrimSpace(filename))
|
||||
if safeName == "." || safeName == ".." || safeName == "" {
|
||||
return ActionLogFile{}, fmt.Errorf("invalid log filename")
|
||||
}
|
||||
if !strings.HasSuffix(safeName, ".json") {
|
||||
return ActionLogFile{}, fmt.Errorf("invalid log filename")
|
||||
}
|
||||
if strings.Contains(safeName, "/") || strings.Contains(safeName, `\`) {
|
||||
return ActionLogFile{}, fmt.Errorf("invalid log filename")
|
||||
}
|
||||
|
||||
path := filepath.Join(ActionLogDir(configDir, nodeID), safeName)
|
||||
cleaned := filepath.Clean(path)
|
||||
if !strings.HasPrefix(cleaned, filepath.Clean(ActionLogDir(configDir, nodeID))+string(os.PathSeparator)) {
|
||||
return ActionLogFile{}, fmt.Errorf("invalid log filename")
|
||||
}
|
||||
return LoadActionLogFile(cleaned)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDiskSafeFilename(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Daily Backup": "Daily_Backup",
|
||||
" hello!! ": "hello",
|
||||
"../etc/passwd": "etc_passwd",
|
||||
"": "unnamed",
|
||||
"a/b\\c:d*e?f": "a_b_c_d_e_f",
|
||||
}
|
||||
for input, want := range cases {
|
||||
got := DiskSafeFilename(input)
|
||||
if got != want {
|
||||
t.Fatalf("DiskSafeFilename(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeActionGroupsRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
now := time.Now().UTC()
|
||||
store := NodeActionGroupStore{
|
||||
Groups: []NodeActionGroup{
|
||||
{
|
||||
ID: "g1",
|
||||
NodeID: "n1",
|
||||
Name: "Nightly",
|
||||
Schedule: ActionGroupSchedule{
|
||||
Enabled: true,
|
||||
Kind: ActionGroupScheduleInterval,
|
||||
EverySeconds: 60,
|
||||
Times: []ActionGroupTimeSpec{},
|
||||
},
|
||||
Items: []NodeActionItem{
|
||||
{
|
||||
ID: "i1",
|
||||
Source: ActionItemSourceLibrary,
|
||||
LibraryActionID: BuiltinActionUptimeID,
|
||||
Env: []ActionEnvVar{},
|
||||
},
|
||||
},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := SaveNodeActionGroups(dir, store); err != nil {
|
||||
t.Fatalf("SaveNodeActionGroups: %v", err)
|
||||
}
|
||||
loaded, err := LoadNodeActionGroups(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadNodeActionGroups: %v", err)
|
||||
}
|
||||
if len(loaded.Groups) != 1 || loaded.Groups[0].Name != "Nightly" {
|
||||
t.Fatalf("loaded = %#v", loaded)
|
||||
}
|
||||
if len(loaded.Groups[0].Items) != 1 {
|
||||
t.Fatalf("items = %#v", loaded.Groups[0].Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsScheduleDueInterval(t *testing.T) {
|
||||
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.Local)
|
||||
group := NodeActionGroup{
|
||||
Schedule: ActionGroupSchedule{
|
||||
Enabled: true,
|
||||
Kind: ActionGroupScheduleInterval,
|
||||
EverySeconds: 300,
|
||||
},
|
||||
}
|
||||
if !IsScheduleDue(group, now) {
|
||||
t.Fatal("expected due when never run")
|
||||
}
|
||||
last := now.Add(-301 * time.Second)
|
||||
group.LastRunAt = &last
|
||||
if !IsScheduleDue(group, now) {
|
||||
t.Fatal("expected due after interval elapsed")
|
||||
}
|
||||
recent := now.Add(-10 * time.Second)
|
||||
group.LastRunAt = &recent
|
||||
if IsScheduleDue(group, now) {
|
||||
t.Fatal("expected not due within interval")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsScheduleDueTimes(t *testing.T) {
|
||||
now := time.Date(2026, 7, 19, 14, 30, 10, 0, time.Local) // Sunday
|
||||
group := NodeActionGroup{
|
||||
Schedule: ActionGroupSchedule{
|
||||
Enabled: true,
|
||||
Kind: ActionGroupScheduleTimes,
|
||||
Times: []ActionGroupTimeSpec{
|
||||
{Time: "14:30", DaysOfWeek: []int{0}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if !IsScheduleDue(group, now) {
|
||||
t.Fatal("expected due at matching time")
|
||||
}
|
||||
sameMinute := now
|
||||
group.LastRunAt = &sameMinute
|
||||
if IsScheduleDue(group, now) {
|
||||
t.Fatal("expected not due again in same minute")
|
||||
}
|
||||
group.LastRunAt = nil
|
||||
group.Schedule.Times[0].DaysOfWeek = []int{1} // Monday only
|
||||
if IsScheduleDue(group, now) {
|
||||
t.Fatal("expected not due on wrong weekday")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionLogAppendAndList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
record := ActionGroupRunRecord{
|
||||
ID: "run-1",
|
||||
NodeID: "node-abc",
|
||||
GroupID: "group-1",
|
||||
GroupName: "Nightly Jobs",
|
||||
Trigger: ActionRunTriggerManual,
|
||||
StartedAt: time.Now().UTC(),
|
||||
FinishedAt: time.Now().UTC(),
|
||||
Actions: []ActionItemRunResult{},
|
||||
}
|
||||
if err := AppendActionLogRun(dir, record); err != nil {
|
||||
t.Fatalf("AppendActionLogRun: %v", err)
|
||||
}
|
||||
path := ActionLogFilePath(dir, "node-abc", "Nightly Jobs")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected log file at %s: %v", path, err)
|
||||
}
|
||||
files, err := ListActionLogFiles(dir, "node-abc")
|
||||
if err != nil {
|
||||
t.Fatalf("ListActionLogFiles: %v", err)
|
||||
}
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("files = %#v", files)
|
||||
}
|
||||
wantName := DiskSafeFilename("Nightly Jobs") + ".json"
|
||||
if files[0].Filename != wantName {
|
||||
t.Fatalf("filename = %q want %q", files[0].Filename, wantName)
|
||||
}
|
||||
loaded, err := ReadActionLogFileByName(dir, "node-abc", wantName)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadActionLogFileByName: %v", err)
|
||||
}
|
||||
if len(loaded.Runs) != 1 || loaded.Runs[0].ID != "run-1" {
|
||||
t.Fatalf("loaded = %#v", loaded)
|
||||
}
|
||||
if _, err := ReadActionLogFileByName(dir, "node-abc", "../etc/passwd"); err == nil {
|
||||
t.Fatal("expected path traversal rejected")
|
||||
}
|
||||
_ = filepath.Base(wantName)
|
||||
}
|
||||
@@ -20,7 +20,9 @@ const (
|
||||
NodeKeysFileName = "node-keys.enc"
|
||||
NodeAuditFileName = "node-audit.json"
|
||||
AuthAuditFileName = "auth-audit.json"
|
||||
ActionsFileName = "actions.json"
|
||||
ActionsFileName = "actions.json"
|
||||
NodeActionGroupsFileName = "node-action-groups.json"
|
||||
ActionLogsDirName = "action-logs"
|
||||
)
|
||||
|
||||
// ResolveDir returns the config directory using precedence:
|
||||
|
||||
@@ -144,6 +144,116 @@ type ActionStore struct {
|
||||
Actions []Action `json:"actions"`
|
||||
}
|
||||
|
||||
// ActionItemSource identifies whether a node action item is a library ref or local copy.
|
||||
type ActionItemSource string
|
||||
|
||||
const (
|
||||
ActionItemSourceLibrary ActionItemSource = "library"
|
||||
ActionItemSourceLocal ActionItemSource = "local"
|
||||
)
|
||||
|
||||
// ActionGroupScheduleKind identifies how an action group is scheduled.
|
||||
type ActionGroupScheduleKind string
|
||||
|
||||
const (
|
||||
ActionGroupScheduleInterval ActionGroupScheduleKind = "interval"
|
||||
ActionGroupScheduleTimes ActionGroupScheduleKind = "times"
|
||||
)
|
||||
|
||||
// ActionGroupTimeSpec is one clock time (and optional weekdays) for a times schedule.
|
||||
type ActionGroupTimeSpec struct {
|
||||
Time string `json:"time"` // "HH:MM" in server local time
|
||||
DaysOfWeek []int `json:"days_of_week,omitempty"` // 0=Sun..6=Sat; empty = every day
|
||||
}
|
||||
|
||||
// ActionGroupSchedule configures when an action group runs automatically.
|
||||
type ActionGroupSchedule struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Kind ActionGroupScheduleKind `json:"kind"`
|
||||
EverySeconds int `json:"every_seconds,omitempty"`
|
||||
Times []ActionGroupTimeSpec `json:"times,omitempty"`
|
||||
}
|
||||
|
||||
// NodeActionItem is one ordered action inside a node action group.
|
||||
type NodeActionItem struct {
|
||||
ID string `json:"id"`
|
||||
Source ActionItemSource `json:"source"`
|
||||
LibraryActionID string `json:"library_action_id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Kind ActionKind `json:"kind,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Env []ActionEnvVar `json:"env,omitempty"`
|
||||
}
|
||||
|
||||
// NodeActionGroup is a named ordered set of actions with one schedule on a node.
|
||||
type NodeActionGroup struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"node_id"`
|
||||
Name string `json:"name"`
|
||||
Schedule ActionGroupSchedule `json:"schedule"`
|
||||
Items []NodeActionItem `json:"items"`
|
||||
LastRunAt *time.Time `json:"last_run_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// NodeActionGroupStore is the plain JSON payload in node-action-groups.json.
|
||||
type NodeActionGroupStore struct {
|
||||
Groups []NodeActionGroup `json:"groups"`
|
||||
}
|
||||
|
||||
// ActionRunTrigger identifies what started an action run.
|
||||
type ActionRunTrigger string
|
||||
|
||||
const (
|
||||
ActionRunTriggerManual ActionRunTrigger = "manual"
|
||||
ActionRunTriggerSchedule ActionRunTrigger = "schedule"
|
||||
)
|
||||
|
||||
// ActionItemRunResult is the captured output of one action within a run.
|
||||
type ActionItemRunResult struct {
|
||||
ItemID string `json:"item_id"`
|
||||
ActionName string `json:"action_name"`
|
||||
Source string `json:"source"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
Error string `json:"error,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt time.Time `json:"finished_at"`
|
||||
}
|
||||
|
||||
// ActionGroupRunRecord is one execution of a group (or a single-item manual run).
|
||||
type ActionGroupRunRecord struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"node_id"`
|
||||
GroupID string `json:"group_id"`
|
||||
GroupName string `json:"group_name"`
|
||||
Trigger ActionRunTrigger `json:"trigger"`
|
||||
Actor string `json:"actor,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt time.Time `json:"finished_at"`
|
||||
Actions []ActionItemRunResult `json:"actions"`
|
||||
}
|
||||
|
||||
// ActionLogFile is the JSON payload for one node/group log file on disk.
|
||||
type ActionLogFile struct {
|
||||
NodeID string `json:"node_id"`
|
||||
GroupID string `json:"group_id"`
|
||||
GroupName string `json:"group_name"`
|
||||
Runs []ActionGroupRunRecord `json:"runs"`
|
||||
}
|
||||
|
||||
// ActionLogFileInfo is metadata for listing log files without reading full content.
|
||||
type ActionLogFileInfo struct {
|
||||
Filename string `json:"filename"`
|
||||
GroupName string `json:"group_name"`
|
||||
GroupID string `json:"group_id,omitempty"`
|
||||
ModTime time.Time `json:"mod_time"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
// NodeKeyEntry holds private key material for one node, keyed by node UUID.
|
||||
type NodeKeyEntry struct {
|
||||
NodeID string `json:"node_id"`
|
||||
|
||||
Reference in New Issue
Block a user