mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Canonicalize config persistence file roots
This commit is contained in:
parent
22a59a8646
commit
4a27e54d09
3 changed files with 182 additions and 22 deletions
|
|
@ -174,6 +174,10 @@ history, and chat sessions must not keep legacy plaintext files on the runtime
|
|||
primary path once the process can read them. Plaintext AI persistence files may
|
||||
only serve as migration input and must be rewritten immediately into
|
||||
encrypted-at-rest storage on load.
|
||||
That same config-persistence boundary also owns fixed runtime file paths: the
|
||||
resolved data directory must be normalized once and fixed AI/runtime filenames
|
||||
must rejoin through the shared storage-path helper instead of raw
|
||||
`filepath.Join(dataDir, "...")` construction.
|
||||
That same persistence boundary also governs AI memory package storage roots:
|
||||
fixed store files such as change history, incident memory, and remediation
|
||||
history must resolve through normalized owned data directories and fixed
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import (
|
|||
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
|
|
@ -87,6 +88,132 @@ type alertScheduleFieldPresence struct {
|
|||
NotifyOnResolve *bool `json:"notifyOnResolve"`
|
||||
}
|
||||
|
||||
type resolvedConfigPersistencePaths struct {
|
||||
alertFile string
|
||||
emailFile string
|
||||
webhookFile string
|
||||
appriseFile string
|
||||
nodesFile string
|
||||
trueNASFile string
|
||||
systemFile string
|
||||
ssoFile string
|
||||
apiTokensFile string
|
||||
aiFile string
|
||||
aiFindingsFile string
|
||||
aiPatrolRunsFile string
|
||||
aiUsageHistoryFile string
|
||||
agentProfilesFile string
|
||||
agentAssignmentsFile string
|
||||
aiChatSessionsFile string
|
||||
orgFile string
|
||||
relayFile string
|
||||
}
|
||||
|
||||
func resolveConfigPersistencePaths(configDir string) (string, resolvedConfigPersistencePaths, error) {
|
||||
normalizedConfigDir, err := securityutil.NormalizeStorageDir(ResolveRuntimeDataDir(configDir))
|
||||
if err != nil {
|
||||
return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve config directory: %w", err)
|
||||
}
|
||||
|
||||
resolveLeaf := func(leaf string) (string, error) {
|
||||
return securityutil.JoinStorageLeaf(normalizedConfigDir, leaf)
|
||||
}
|
||||
|
||||
alertFile, err := resolveLeaf("alerts.json")
|
||||
if err != nil {
|
||||
return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve alerts.json: %w", err)
|
||||
}
|
||||
emailFile, err := resolveLeaf("email.enc")
|
||||
if err != nil {
|
||||
return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve email.enc: %w", err)
|
||||
}
|
||||
webhookFile, err := resolveLeaf("webhooks.enc")
|
||||
if err != nil {
|
||||
return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve webhooks.enc: %w", err)
|
||||
}
|
||||
appriseFile, err := resolveLeaf("apprise.enc")
|
||||
if err != nil {
|
||||
return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve apprise.enc: %w", err)
|
||||
}
|
||||
nodesFile, err := resolveLeaf("nodes.enc")
|
||||
if err != nil {
|
||||
return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve nodes.enc: %w", err)
|
||||
}
|
||||
trueNASFile, err := resolveLeaf("truenas.enc")
|
||||
if err != nil {
|
||||
return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve truenas.enc: %w", err)
|
||||
}
|
||||
systemFile, err := resolveLeaf("system.json")
|
||||
if err != nil {
|
||||
return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve system.json: %w", err)
|
||||
}
|
||||
ssoFile, err := resolveLeaf("sso.enc")
|
||||
if err != nil {
|
||||
return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve sso.enc: %w", err)
|
||||
}
|
||||
apiTokensFile, err := resolveLeaf("api_tokens.json")
|
||||
if err != nil {
|
||||
return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve api_tokens.json: %w", err)
|
||||
}
|
||||
aiFile, err := resolveLeaf("ai.enc")
|
||||
if err != nil {
|
||||
return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve ai.enc: %w", err)
|
||||
}
|
||||
aiFindingsFile, err := resolveLeaf("ai_findings.json")
|
||||
if err != nil {
|
||||
return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve ai_findings.json: %w", err)
|
||||
}
|
||||
aiPatrolRunsFile, err := resolveLeaf("ai_patrol_runs.json")
|
||||
if err != nil {
|
||||
return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve ai_patrol_runs.json: %w", err)
|
||||
}
|
||||
aiUsageHistoryFile, err := resolveLeaf("ai_usage_history.json")
|
||||
if err != nil {
|
||||
return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve ai_usage_history.json: %w", err)
|
||||
}
|
||||
agentProfilesFile, err := resolveLeaf("agent_profiles.json")
|
||||
if err != nil {
|
||||
return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve agent_profiles.json: %w", err)
|
||||
}
|
||||
agentAssignmentsFile, err := resolveLeaf("agent_profile_assignments.json")
|
||||
if err != nil {
|
||||
return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve agent_profile_assignments.json: %w", err)
|
||||
}
|
||||
aiChatSessionsFile, err := resolveLeaf("ai_chat_sessions.json")
|
||||
if err != nil {
|
||||
return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve ai_chat_sessions.json: %w", err)
|
||||
}
|
||||
orgFile, err := resolveLeaf("org.json")
|
||||
if err != nil {
|
||||
return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve org.json: %w", err)
|
||||
}
|
||||
relayFile, err := resolveLeaf("relay.enc")
|
||||
if err != nil {
|
||||
return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve relay.enc: %w", err)
|
||||
}
|
||||
|
||||
return normalizedConfigDir, resolvedConfigPersistencePaths{
|
||||
alertFile: alertFile,
|
||||
emailFile: emailFile,
|
||||
webhookFile: webhookFile,
|
||||
appriseFile: appriseFile,
|
||||
nodesFile: nodesFile,
|
||||
trueNASFile: trueNASFile,
|
||||
systemFile: systemFile,
|
||||
ssoFile: ssoFile,
|
||||
apiTokensFile: apiTokensFile,
|
||||
aiFile: aiFile,
|
||||
aiFindingsFile: aiFindingsFile,
|
||||
aiPatrolRunsFile: aiPatrolRunsFile,
|
||||
aiUsageHistoryFile: aiUsageHistoryFile,
|
||||
agentProfilesFile: agentProfilesFile,
|
||||
agentAssignmentsFile: agentAssignmentsFile,
|
||||
aiChatSessionsFile: aiChatSessionsFile,
|
||||
orgFile: orgFile,
|
||||
relayFile: relayFile,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewConfigPersistence creates a new config persistence manager.
|
||||
// The process terminates if encryption cannot be initialized to avoid
|
||||
// writing secrets to disk in plaintext.
|
||||
|
|
@ -102,40 +229,43 @@ func NewConfigPersistence(configDir string) *ConfigPersistence {
|
|||
}
|
||||
|
||||
func newConfigPersistence(configDir string) (*ConfigPersistence, error) {
|
||||
configDir = ResolveRuntimeDataDir(configDir)
|
||||
resolvedConfigDir, resolvedPaths, err := resolveConfigPersistencePaths(configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize crypto manager
|
||||
cryptoMgr, err := crypto.NewCryptoManagerAt(configDir)
|
||||
cryptoMgr, err := crypto.NewCryptoManagerAt(resolvedConfigDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cp := &ConfigPersistence{
|
||||
configDir: configDir,
|
||||
alertFile: filepath.Join(configDir, "alerts.json"),
|
||||
emailFile: filepath.Join(configDir, "email.enc"),
|
||||
webhookFile: filepath.Join(configDir, "webhooks.enc"),
|
||||
appriseFile: filepath.Join(configDir, "apprise.enc"),
|
||||
nodesFile: filepath.Join(configDir, "nodes.enc"),
|
||||
trueNASFile: filepath.Join(configDir, "truenas.enc"),
|
||||
systemFile: filepath.Join(configDir, "system.json"),
|
||||
ssoFile: filepath.Join(configDir, "sso.enc"),
|
||||
apiTokensFile: filepath.Join(configDir, "api_tokens.json"),
|
||||
aiFile: filepath.Join(configDir, "ai.enc"),
|
||||
aiFindingsFile: filepath.Join(configDir, "ai_findings.json"),
|
||||
aiPatrolRunsFile: filepath.Join(configDir, "ai_patrol_runs.json"),
|
||||
aiUsageHistoryFile: filepath.Join(configDir, "ai_usage_history.json"),
|
||||
agentProfilesFile: filepath.Join(configDir, "agent_profiles.json"),
|
||||
agentAssignmentsFile: filepath.Join(configDir, "agent_profile_assignments.json"),
|
||||
aiChatSessionsFile: filepath.Join(configDir, "ai_chat_sessions.json"),
|
||||
orgFile: filepath.Join(configDir, "org.json"),
|
||||
relayFile: filepath.Join(configDir, "relay.enc"),
|
||||
configDir: resolvedConfigDir,
|
||||
alertFile: resolvedPaths.alertFile,
|
||||
emailFile: resolvedPaths.emailFile,
|
||||
webhookFile: resolvedPaths.webhookFile,
|
||||
appriseFile: resolvedPaths.appriseFile,
|
||||
nodesFile: resolvedPaths.nodesFile,
|
||||
trueNASFile: resolvedPaths.trueNASFile,
|
||||
systemFile: resolvedPaths.systemFile,
|
||||
ssoFile: resolvedPaths.ssoFile,
|
||||
apiTokensFile: resolvedPaths.apiTokensFile,
|
||||
aiFile: resolvedPaths.aiFile,
|
||||
aiFindingsFile: resolvedPaths.aiFindingsFile,
|
||||
aiPatrolRunsFile: resolvedPaths.aiPatrolRunsFile,
|
||||
aiUsageHistoryFile: resolvedPaths.aiUsageHistoryFile,
|
||||
agentProfilesFile: resolvedPaths.agentProfilesFile,
|
||||
agentAssignmentsFile: resolvedPaths.agentAssignmentsFile,
|
||||
aiChatSessionsFile: resolvedPaths.aiChatSessionsFile,
|
||||
orgFile: resolvedPaths.orgFile,
|
||||
relayFile: resolvedPaths.relayFile,
|
||||
crypto: cryptoMgr,
|
||||
fs: defaultFileSystem{},
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("configDir", configDir).
|
||||
Str("configDir", resolvedConfigDir).
|
||||
Str("systemFile", cp.systemFile).
|
||||
Str("nodesFile", cp.nodesFile).
|
||||
Bool("encryptionEnabled", cryptoMgr != nil).
|
||||
|
|
|
|||
|
|
@ -37,3 +37,29 @@ func TestNewConfigPersistence_Scenarios(t *testing.T) {
|
|||
assert.Error(t, err, "Expected error when configDir is a file")
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewConfigPersistence_CanonicalizesOwnedConfigPaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
explicit := filepath.Join(root, "nested", "..", "nested")
|
||||
|
||||
cp, err := newConfigPersistence(" " + explicit + " ")
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedDir := filepath.Clean(explicit)
|
||||
assert.Equal(t, expectedDir, cp.configDir)
|
||||
assert.Equal(t, filepath.Join(expectedDir, "alerts.json"), cp.alertFile)
|
||||
assert.Equal(t, filepath.Join(expectedDir, "system.json"), cp.systemFile)
|
||||
assert.Equal(t, filepath.Join(expectedDir, "ai_chat_sessions.json"), cp.aiChatSessionsFile)
|
||||
assert.Equal(t, filepath.Join(expectedDir, "relay.enc"), cp.relayFile)
|
||||
}
|
||||
|
||||
func TestNewConfigPersistence_RejectsBlankResolvedConfigDir(t *testing.T) {
|
||||
originalDefaultDataDir := defaultDataDir
|
||||
defaultDataDir = " \t "
|
||||
t.Cleanup(func() { defaultDataDir = originalDefaultDataDir })
|
||||
t.Setenv("PULSE_DATA_DIR", " \t ")
|
||||
|
||||
_, err := newConfigPersistence(" \t ")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "resolve config directory")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue