Add resiliency primitives: atomic writes, reconcile mutex, state backup
- Add storage.WriteFileAtomic (temp + rename) and storage.CopyFile helpers - Convert all 8 production config writers to atomic writes: config/state.yaml, dnsmasq, nftables, domains, wireguard (config + secrets + peers + wg0.conf), tunnel/cloudflared - Add sync.Mutex to Reconciler to serialize concurrent Reconcile() calls triggered by domain registration goroutines - Add state.yaml backup (.bak) before every write; LoadState falls back to backup if primary is corrupted - Reconciler refuses to use empty config on corruption (only on first run when no state file exists yet)
This commit is contained in:
@@ -3,11 +3,14 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/wild-cloud/wild-central/internal/storage"
|
||||
)
|
||||
|
||||
// ExtraPort is a TCP or UDP port that the firewall should allow on the WAN interface.
|
||||
@@ -155,24 +158,41 @@ func (s *State) RemoveDHCPStaticLease(mac string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// LoadState loads state from the specified path
|
||||
// LoadState loads state from the specified path. If the primary file is
|
||||
// corrupted, falls back to the .bak backup if one exists.
|
||||
func LoadState(configPath string) (*State, error) {
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading config file %s: %w", configPath, err)
|
||||
state, err := loadStateFrom(configPath)
|
||||
if err == nil {
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// Primary failed — try backup
|
||||
bakPath := configPath + ".bak"
|
||||
bakState, bakErr := loadStateFrom(bakPath)
|
||||
if bakErr == nil {
|
||||
slog.Warn("loaded state from backup (primary corrupted)", "component", "config",
|
||||
"path", configPath, "error", err)
|
||||
return bakState, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("reading config file %s: %w", configPath, err)
|
||||
}
|
||||
|
||||
func loadStateFrom(path string) (*State, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config := &State{}
|
||||
if err := yaml.Unmarshal(data, config); err != nil {
|
||||
return nil, fmt.Errorf("parsing config file: %w", err)
|
||||
return nil, fmt.Errorf("parsing %s: %w", path, err)
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// SaveState saves the state to the specified path
|
||||
// SaveState saves the state to the specified path. Backs up the current
|
||||
// file to .bak before writing so LoadState can recover from corruption.
|
||||
func SaveState(config *State, configPath string) error {
|
||||
// Ensure the directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
|
||||
return fmt.Errorf("creating config directory: %w", err)
|
||||
}
|
||||
@@ -182,5 +202,10 @@ func SaveState(config *State, configPath string) error {
|
||||
return fmt.Errorf("marshaling config: %w", err)
|
||||
}
|
||||
|
||||
return os.WriteFile(configPath, data, 0644)
|
||||
// Backup current state before overwriting
|
||||
if storage.FileExists(configPath) {
|
||||
_ = storage.CopyFile(configPath, configPath+".bak")
|
||||
}
|
||||
|
||||
return storage.WriteFileAtomic(configPath, data, 0644)
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ func TestLoadState_Errors(t *testing.T) {
|
||||
}
|
||||
return statePath
|
||||
},
|
||||
errContains: "parsing config file",
|
||||
errContains: "yaml:",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user