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
+174
View File
@@ -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)
}