Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 137 additions & 29 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,39 @@ var (
configMutex sync.RWMutex
statusMutex sync.RWMutex

cachedConfig *Config
cachedStatus *Status

// ContainerNameRegex defines valid characters for a Docker container name.
ContainerNameRegex = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
)

func deepCopyConfig(src *Config) (*Config, error) {
if src == nil {
return nil, nil
}
data, err := json.Marshal(src)
if err != nil {
return nil, err
}
var dst Config
err = json.Unmarshal(data, &dst)
return &dst, err
}

func deepCopyStatus(src *Status) (*Status, error) {
if src == nil {
return nil, nil
}
data, err := json.Marshal(src)
if err != nil {
return nil, err
}
var dst Status
err = json.Unmarshal(data, &dst)
return &dst, err
}

// IsValidContainerName checks if a string is a valid Docker container name.
func IsValidContainerName(name string) bool {
return ContainerNameRegex.MatchString(name)
Expand Down Expand Up @@ -116,7 +145,19 @@ type ServiceStatus struct {
// LoadConfig reads the configuration file. It creates one with defaults if it doesn't exist.
func LoadConfig() (*Config, error) {
configMutex.RLock()
defer configMutex.RUnlock()
if cachedConfig != nil {
cfg, err := deepCopyConfig(cachedConfig)
configMutex.RUnlock()
return cfg, err
}
configMutex.RUnlock()

configMutex.Lock()
defer configMutex.Unlock()

if cachedConfig != nil {
return deepCopyConfig(cachedConfig)
}

path := filepath.Join(ConfigDir, ConfigFile)
// #nosec G304
Expand All @@ -140,7 +181,8 @@ func LoadConfig() (*Config, error) {
}
}

return &cfg, nil
cachedConfig = &cfg
return deepCopyConfig(cachedConfig)
}

// SaveConfig writes the configuration file in a thread-safe manner.
Expand All @@ -158,13 +200,30 @@ func SaveConfig(cfg *Config) error {
return err
}

return os.WriteFile(path, data, 0600)
if err := os.WriteFile(path, data, 0600); err != nil {
return err
}

cachedConfig, _ = deepCopyConfig(cfg)
return nil
}

// LoadStatus reads the status file.
func LoadStatus() (*Status, error) {
statusMutex.RLock()
defer statusMutex.RUnlock()
if cachedStatus != nil {
status, err := deepCopyStatus(cachedStatus)
statusMutex.RUnlock()
return status, err
}
statusMutex.RUnlock()

statusMutex.Lock()
defer statusMutex.Unlock()

if cachedStatus != nil {
return deepCopyStatus(cachedStatus)
}

path := filepath.Join(ConfigDir, StatusFile)
// #nosec G304
Expand All @@ -181,7 +240,8 @@ func LoadStatus() (*Status, error) {
return nil, fmt.Errorf("failed to parse status json: %v", err)
}

return &status, nil
cachedStatus = &status
return deepCopyStatus(cachedStatus)
}

// SaveStatus writes the status file in a thread-safe manner.
Expand All @@ -199,31 +259,46 @@ func SaveStatus(status *Status) error {
return err
}

return os.WriteFile(path, data, 0600)
if err := os.WriteFile(path, data, 0600); err != nil {
return err
}

cachedStatus, _ = deepCopyStatus(status)
return nil
}

// UpdateStatus atomically updates the status using a callback function.
func UpdateStatus(updateFn func(*Status)) error {
statusMutex.Lock()
defer statusMutex.Unlock()

path := filepath.Join(ConfigDir, StatusFile)
// #nosec G304
data, err := os.ReadFile(path)
var status Status
if err != nil {
if !os.IsNotExist(err) {
var status *Status
var err error
if cachedStatus != nil {
status, err = deepCopyStatus(cachedStatus)
if err != nil {
return err
}
status.Services = []ServiceStatus{}
} else {
if err := json.Unmarshal(data, &status); err != nil {
return err
path := filepath.Join(ConfigDir, StatusFile)
// #nosec G304
data, err := os.ReadFile(path)
if err != nil {
if !os.IsNotExist(err) {
return err
}
status = &Status{Services: []ServiceStatus{}}
} else {
status = &Status{}
if err := json.Unmarshal(data, status); err != nil {
return err
}
}
}

updateFn(&status)
updateFn(status)

path := filepath.Join(ConfigDir, StatusFile)
if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil {
return err
}
Expand All @@ -233,32 +308,49 @@ func UpdateStatus(updateFn func(*Status)) error {
return err
}

return os.WriteFile(path, newData, 0600)
if err := os.WriteFile(path, newData, 0600); err != nil {
return err
}

cachedStatus = status
return nil
}

// UpdateConfig atomically updates the configuration using a callback function.
func UpdateConfig(updateFn func(*Config)) error {
configMutex.Lock()
defer configMutex.Unlock()

path := filepath.Join(ConfigDir, ConfigFile)
// #nosec G304
data, err := os.ReadFile(path)
var cfg Config
if err != nil {
if !os.IsNotExist(err) {
var cfg *Config
var err error
if cachedConfig != nil {
cfg, err = deepCopyConfig(cachedConfig)
if err != nil {
return err
}
cfg.Users = make(map[string]string)
cfg.Services = []ServiceConfig{}
} else {
if err := json.Unmarshal(data, &cfg); err != nil {
return err
path := filepath.Join(ConfigDir, ConfigFile)
// #nosec G304
data, err := os.ReadFile(path)
if err != nil {
if !os.IsNotExist(err) {
return err
}
cfg = &Config{
Users: make(map[string]string),
Services: []ServiceConfig{},
}
} else {
cfg = &Config{}
if err := json.Unmarshal(data, cfg); err != nil {
return err
}
}
}

updateFn(&cfg)
updateFn(cfg)

path := filepath.Join(ConfigDir, ConfigFile)
if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil {
return err
}
Expand All @@ -268,5 +360,21 @@ func UpdateConfig(updateFn func(*Config)) error {
return err
}

return os.WriteFile(path, newData, 0600)
if err := os.WriteFile(path, newData, 0600); err != nil {
return err
}

cachedConfig = cfg
return nil
}

// ClearCache clears the in-memory cache for testing purposes.
func clearCache() {
configMutex.Lock()
cachedConfig = nil
configMutex.Unlock()

statusMutex.Lock()
cachedStatus = nil
statusMutex.Unlock()
}
68 changes: 68 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ func TestAtomicUpdates(t *testing.T) {
}

func TestLoadNonExistent(t *testing.T) {
clearCache()
tmpDir := t.TempDir()
originalDir := ConfigDir
SetConfigDir(tmpDir)
Expand All @@ -163,6 +164,7 @@ func TestLoadNonExistent(t *testing.T) {
}

func TestLoadInvalidJSON(t *testing.T) {
clearCache()
tmpDir := t.TempDir()
originalDir := ConfigDir
SetConfigDir(tmpDir)
Expand All @@ -186,6 +188,7 @@ func TestLoadInvalidJSON(t *testing.T) {
}

func TestLoadConfigDefaults(t *testing.T) {
clearCache()
tmpDir := t.TempDir()
originalDir := ConfigDir
SetConfigDir(tmpDir)
Expand Down Expand Up @@ -217,6 +220,7 @@ func TestLoadConfigDefaults(t *testing.T) {
}

func TestUpdateStatusNewFile(t *testing.T) {
clearCache()
tmpDir := t.TempDir()
originalDir := ConfigDir
SetConfigDir(tmpDir)
Expand Down Expand Up @@ -245,6 +249,7 @@ func TestUpdateStatusNewFile(t *testing.T) {
}

func TestUpdateConfigNewFile(t *testing.T) {
clearCache()
tmpDir := t.TempDir()
originalDir := ConfigDir
SetConfigDir(tmpDir)
Expand Down Expand Up @@ -273,6 +278,7 @@ func TestUpdateConfigNewFile(t *testing.T) {
}

func TestUpdateInvalidJSON(t *testing.T) {
clearCache()
tmpDir := t.TempDir()
originalDir := ConfigDir
SetConfigDir(tmpDir)
Expand All @@ -294,3 +300,65 @@ func TestUpdateInvalidJSON(t *testing.T) {
t.Error("Expected error when updating invalid status JSON, got nil")
}
}
func TestConfigCaching(t *testing.T) {
tmpDir := t.TempDir()
originalDir := ConfigDir
SetConfigDir(tmpDir)
clearCache()
t.Cleanup(func() {
SetConfigDir(originalDir)
clearCache()
})

cfg := &Config{
Users: map[string]string{"admin": "hash"},
Services: []ServiceConfig{
{
Name: "Service1",
AcceptedStatusCodes: []int{200},
},
},
}

if err := SaveConfig(cfg); err != nil {
t.Fatalf("SaveConfig failed: %v", err)
}

// Load should come from cache
loaded1, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig 1 failed: %v", err)
}

// Manually corrupt the file on disk
path := filepath.Join(tmpDir, ConfigFile)
if err := os.WriteFile(path, []byte("corrupted json"), 0600); err != nil {
t.Fatalf("Failed to corrupt config file: %v", err)
}

// Load should still succeed because it's cached
loaded2, err := LoadConfig()
if err != nil {
t.Errorf("LoadConfig 2 failed after disk corruption (should have been cached): %v", err)
}

if !jsonEqual(loaded1, loaded2) {
t.Errorf("Cached config changed unexpectedly")
}

// UpdateConfig should refresh the cache
err = UpdateConfig(func(c *Config) {
c.Services[0].Name = "UpdatedService"
})
if err != nil {
t.Fatalf("UpdateConfig failed: %v", err)
}

loaded3, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig 3 failed: %v", err)
}
if loaded3.Services[0].Name != "UpdatedService" {
t.Errorf("Expected UpdatedService, got %s", loaded3.Services[0].Name)
}
}
6 changes: 5 additions & 1 deletion internal/monitor/monitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,11 @@ func monitorService(svc config.ServiceConfig) {

lastRestart := readLastRestart(svc.Name)

ticker := time.NewTicker(time.Duration(svc.Interval) * time.Second)
interval := svc.Interval
if interval <= 0 {
interval = 60 // Default to 60 seconds if invalid
}
ticker := time.NewTicker(time.Duration(interval) * time.Second)
defer ticker.Stop()

for {
Expand Down
Loading