fix: A single entities read failure wont clear all entities

This commit is contained in:
jamesread 2026-07-25 22:20:52 +01:00
parent b000e82238
commit 18903853fb
4 changed files with 128 additions and 7 deletions

View File

@ -104,7 +104,6 @@ func loadEntityFileJson(filename string, entityname string) {
if err != nil { if err != nil {
log.Errorf("ReadIn: %v", err) log.Errorf("ReadIn: %v", err)
ClearEntitiesOfType(entityname)
return return
} }
@ -119,7 +118,6 @@ func loadEntityFileJson(filename string, entityname string) {
if err != nil { if err != nil {
log.Errorf("%v", err) log.Errorf("%v", err)
ClearEntitiesOfType(entityname)
return return
} }
@ -139,7 +137,6 @@ func loadEntityFileYaml(filename string, entityname string) {
if err != nil { if err != nil {
log.Errorf("ReadIn: %v", err) log.Errorf("ReadIn: %v", err)
ClearEntitiesOfType(entityname)
return return
} }
@ -149,7 +146,6 @@ func loadEntityFileYaml(filename string, entityname string) {
if err != nil { if err != nil {
log.Errorf("Unmarshal: %v", err) log.Errorf("Unmarshal: %v", err)
ClearEntitiesOfType(entityname)
return return
} }

View File

@ -1,6 +1,8 @@
package entities package entities
import ( import (
"os"
"path/filepath"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -59,3 +61,34 @@ func TestGetEntityInstancesOrdered_emptyOrMissing(t *testing.T) {
ordered = GetEntityInstancesOrdered("empty_test") ordered = GetEntityInstancesOrdered("empty_test")
assert.Nil(t, ordered) assert.Nil(t, ordered)
} }
func TestLoadEntityFile_preservesEntitiesOnTransientFailure(t *testing.T) {
const entityName = "preserve_on_fail"
ClearEntitiesOfType(entityName)
defer ClearEntitiesOfType(entityName)
dir := t.TempDir()
yamlPath := filepath.Join(dir, "hosts.yaml")
require.NoError(t, os.WriteFile(yamlPath, []byte("- title: kept\n"), 0o600))
loadEntityFile(yamlPath, entityName)
require.Len(t, GetEntityInstancesOrdered(entityName), 1)
loadEntityFile(filepath.Join(dir, "missing.yaml"), entityName)
require.Len(t, GetEntityInstancesOrdered(entityName), 1, "read failure should keep last good entities")
require.NoError(t, os.WriteFile(yamlPath, []byte("not: valid: yaml: ["), 0o600))
loadEntityFile(yamlPath, entityName)
require.Len(t, GetEntityInstancesOrdered(entityName), 1, "parse failure should keep last good entities")
jsonPath := filepath.Join(dir, "hosts.json")
require.NoError(t, os.WriteFile(jsonPath, []byte("{\"title\":\"json-kept\"}\n"), 0o600))
loadEntityFile(jsonPath, entityName)
require.Len(t, GetEntityInstancesOrdered(entityName), 1)
require.NoError(t, os.WriteFile(jsonPath, []byte("{bad json"), 0o600))
loadEntityFile(jsonPath, entityName)
ordered := GetEntityInstancesOrdered(entityName)
require.Len(t, ordered, 1, "JSON parse failure should keep last good entities")
assert.Equal(t, "json-kept", ordered[0].Title)
}

View File

@ -1391,6 +1391,11 @@ func triggerLoop(req *ExecutionRequest) {
} }
func stepSaveLog(req *ExecutionRequest) bool { func stepSaveLog(req *ExecutionRequest) bool {
if !canSaveExecutionLog(req) {
log.Warnf("Cannot save execution log; missing request, log entry, binding/action, or config")
return false
}
filename := fmt.Sprintf("%v.%v.%v", sanitizeLogFilename(req.logEntry.ActionTitle), req.logEntry.DatetimeStarted.Unix(), req.logEntry.ExecutionTrackingID) filename := fmt.Sprintf("%v.%v.%v", sanitizeLogFilename(req.logEntry.ActionTitle), req.logEntry.DatetimeStarted.Unix(), req.logEntry.ExecutionTrackingID)
saveLogResults(req, filename) saveLogResults(req, filename)
@ -1399,10 +1404,14 @@ func stepSaveLog(req *ExecutionRequest) bool {
return true return true
} }
func canSaveExecutionLog(req *ExecutionRequest) bool {
return req != nil && req.logEntry != nil && req.Binding != nil && req.Binding.Action != nil && req.Cfg != nil
}
// sanitizeLogFilename replaces characters that are unsafe in filenames so action // sanitizeLogFilename replaces characters that are unsafe in filenames so action
// titles like "Create/update Report" do not create nested paths or fail to write. // titles like "Create/update Report" do not create nested paths or fail to write.
func sanitizeLogFilename(title string) string { func sanitizeLogFilename(title string) string {
replacer := strings.NewReplacer( oldnew := []string{
"/", "_", "/", "_",
"\\", "_", "\\", "_",
":", "_", ":", "_",
@ -1412,9 +1421,15 @@ func sanitizeLogFilename(title string) string {
"<", "_", "<", "_",
">", "_", ">", "_",
"|", "_", "|", "_",
) }
return replacer.Replace(title) // NUL and other C0 controls plus DEL are invalid or problematic in filenames.
for i := 0; i < 32; i++ {
oldnew = append(oldnew, string(rune(i)), "_")
}
oldnew = append(oldnew, "\x7f", "_")
return strings.NewReplacer(oldnew...).Replace(title)
} }
func firstNonEmpty(one, two string) string { func firstNonEmpty(one, two string) string {

View File

@ -790,6 +790,8 @@ func TestSanitizeLogFilename(t *testing.T) {
{"Create/update Monthly Report", "Create_update Monthly Report"}, {"Create/update Monthly Report", "Create_update Monthly Report"},
{`path\with\backslashes`, "path_with_backslashes"}, {`path\with\backslashes`, "path_with_backslashes"},
{`a:b*c?d"e<f>g|h`, "a_b_c_d_e_f_g_h"}, {`a:b*c?d"e<f>g|h`, "a_b_c_d_e_f_g_h"},
{"has\x00nul", "has_nul"},
{"tab\there\nand\rreturn", "tab_here_and_return"},
} }
for _, tt := range tests { for _, tt := range tests {
@ -870,3 +872,78 @@ func TestStepSaveLogKeepsSafeTitleFilename(t *testing.T) {
expectedPath := filepath.Join(resultsDir, "Echo Test.1714333384."+trackingID+".yaml") expectedPath := filepath.Join(resultsDir, "Echo Test.1714333384."+trackingID+".yaml")
assert.FileExists(t, expectedPath) assert.FileExists(t, expectedPath)
} }
func TestStepSaveLogSanitizesNULInTitle(t *testing.T) {
resultsDir := t.TempDir()
outputDir := t.TempDir()
started := time.Unix(1714333384, 0)
trackingID := "bbbbbbbb-cccc-dddd-eeee-ffffffffffff"
title := "Bad\x00Title"
req := &ExecutionRequest{
Cfg: &config.Config{
SaveLogs: config.SaveLogsConfig{
ResultsDirectory: resultsDir,
OutputDirectory: outputDir,
},
},
Binding: &ActionBinding{
Action: &config.Action{},
},
logEntry: &InternalLogEntry{
ActionTitle: title,
DatetimeStarted: started,
ExecutionTrackingID: trackingID,
Output: "nul ok",
},
}
assert.True(t, stepSaveLog(req))
expectedBase := "Bad_Title.1714333384." + trackingID
resultsPath := filepath.Join(resultsDir, expectedBase+".yaml")
outputPath := filepath.Join(outputDir, expectedBase+".log")
assert.FileExists(t, resultsPath)
assert.FileExists(t, outputPath)
assert.NotContains(t, resultsPath, "\x00")
assert.NotContains(t, outputPath, "\x00")
output, err := os.ReadFile(outputPath)
assert.NoError(t, err)
assert.Equal(t, "nul ok", string(output))
}
func TestStepSaveLogReturnsFalseWhenDependenciesMissing(t *testing.T) {
started := time.Unix(1714333384, 0)
valid := &ExecutionRequest{
Cfg: &config.Config{},
Binding: &ActionBinding{
Action: &config.Action{},
},
logEntry: &InternalLogEntry{
ActionTitle: "Echo",
DatetimeStarted: started,
ExecutionTrackingID: "cccccccc-dddd-eeee-ffff-000000000000",
},
}
assert.False(t, stepSaveLog(nil))
assert.False(t, stepSaveLog(&ExecutionRequest{}))
missingLog := *valid
missingLog.logEntry = nil
assert.False(t, stepSaveLog(&missingLog))
missingBinding := *valid
missingBinding.Binding = nil
assert.False(t, stepSaveLog(&missingBinding))
missingAction := *valid
missingAction.Binding = &ActionBinding{}
assert.False(t, stepSaveLog(&missingAction))
missingCfg := *valid
missingCfg.Cfg = nil
assert.False(t, stepSaveLog(&missingCfg))
}