Skip to content
Draft
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
155 changes: 126 additions & 29 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ 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_.-]+$`)
)
Expand All @@ -31,6 +34,17 @@ func SetConfigDir(dir string) {
ConfigDir = dir
}

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

statusMutex.Lock()
cachedStatus = nil
statusMutex.Unlock()
}

type Config struct {
Users map[string]string `json:"users"`
Services []ServiceConfig `json:"services"`
Expand Down Expand Up @@ -113,10 +127,38 @@ type ServiceStatus struct {
AlertCount int `json:"alert_count"`
}

func deepCopy[T any](src *T) (*T, error) {
if src == nil {
return nil, nil
}
data, err := json.Marshal(src)
if err != nil {
return nil, err
}
var dst T
if err := json.Unmarshal(data, &dst); err != nil {
return nil, err
}
return &dst, nil
}

// 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 {
copy, err := deepCopy(cachedConfig)
configMutex.RUnlock()
return copy, err
}
configMutex.RUnlock()

configMutex.Lock()
defer configMutex.Unlock()

// Double check after acquiring write lock
if cachedConfig != nil {
return deepCopy(cachedConfig)
}

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

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

// SaveConfig writes the configuration file in a thread-safe manner.
Expand All @@ -158,13 +201,31 @@ 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 = cfg
return nil
}

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

statusMutex.Lock()
defer statusMutex.Unlock()

// Double check
if cachedStatus != nil {
return deepCopy(cachedStatus)
}

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

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

// SaveStatus writes the status file in a thread-safe manner.
Expand All @@ -199,31 +261,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 = 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 = deepCopy(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)
status = &Status{}
if err != nil {
if !os.IsNotExist(err) {
return err
}
status.Services = []ServiceStatus{}
} else {
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 +310,47 @@ 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 = deepCopy(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)
cfg = &Config{}
if err != nil {
if !os.IsNotExist(err) {
return err
}
cfg.Users = make(map[string]string)
cfg.Services = []ServiceConfig{}
} else {
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,10 @@ 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
}
9 changes: 9 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ func TestIsValidContainerName(t *testing.T) {
}

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

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

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

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

func TestLoadInvalidJSON(t *testing.T) {
clearCache()
tmpDir := t.TempDir()
originalDir := ConfigDir
SetConfigDir(tmpDir)
Expand All @@ -186,6 +191,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 +223,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 +252,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 +281,7 @@ func TestUpdateConfigNewFile(t *testing.T) {
}

func TestUpdateInvalidJSON(t *testing.T) {
clearCache()
tmpDir := t.TempDir()
originalDir := ConfigDir
SetConfigDir(tmpDir)
Expand Down
3 changes: 3 additions & 0 deletions internal/monitor/monitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,9 @@ func monitorService(svc config.ServiceConfig) {

lastRestart := readLastRestart(svc.Name)

if svc.Interval <= 0 {
svc.Interval = 60
}
ticker := time.NewTicker(time.Duration(svc.Interval) * time.Second)
defer ticker.Stop()

Expand Down
Loading