diff --git a/modules/compass/README.md b/modules/compass/README.md index 50ea836..e5fefde 100644 --- a/modules/compass/README.md +++ b/modules/compass/README.md @@ -19,7 +19,7 @@ Layered YAML configuration with per-environment directories, `SUMMER_` environme 6. In-memory values stored with `compass.Config.Set`. - Typed getters with zero-value defaults: `compass.Config.String`, `compass.Config.Int`, `compass.Config.Bool`, plus `compass.Config.Lookup` and `compass.Config.Has` to tell a missing key from a zero value. - `compass.Config.LoadSection` unmarshals a whole subtree into a struct using `koanf` struct tags. -- Runtime overrides: `compass.Config.Set` changes a value in memory, `compass.Config.Persist` saves all runtime values atomically to the environment's `overrides.yaml` (directory mode 0700, file mode 0600, refusing any path outside the config directory), and `compass.Config.Reload` rereads every source and discards unsaved runtime values. +- Runtime overrides: `compass.Config.Set` changes a value in memory, `compass.Config.Persist` saves all runtime values atomically to the environment's `overrides.yaml`, keeping the keys already saved there and replacing the saved value of any key set at runtime (directory mode 0700, file mode 0600, refusing any path outside the config directory), and `compass.Config.Reload` rereads every source and discards unsaved runtime values. - `compass.Config.Environment` reports the active environment name, which must consist of letters, digits, `-` and `_`. - Safe for concurrent reads and writes. diff --git a/modules/compass/persist.go b/modules/compass/persist.go index a942976..c934bc4 100644 --- a/modules/compass/persist.go +++ b/modules/compass/persist.go @@ -27,7 +27,8 @@ func (c *Config) Set(path string, value any) error { } // Persist writes runtime overrides to config/env//overrides.yaml -// using an atomic replace and restrictive file permissions. +// using an atomic replace and restrictive file permissions. Keys already in +// the file are kept; a runtime value replaces the saved value of its key. func (c *Config) Persist() error { if c == nil { return fmt.Errorf("compass: config is nil") @@ -48,9 +49,20 @@ func (c *Config) Persist() error { if err := os.MkdirAll(destDir, 0o700); err != nil { return fmt.Errorf("compass: create overrides dir: %w", err) } - raw := []byte("{}\n") + // Start from the values persisted earlier, so saving one runtime value + // never drops another key from the file; runtime values win. + merged := koanf.New(".") + if err := loadOverrides(merged, dest); err != nil { + return err + } if c.runtime != nil { - body, err := c.runtime.Marshal(yaml.Parser()) + if err := merged.Merge(c.runtime); err != nil { + return fmt.Errorf("compass: merge overrides: %w", err) + } + } + raw := []byte("{}\n") + if len(merged.Keys()) > 0 { + body, err := merged.Marshal(yaml.Parser()) if err != nil { return fmt.Errorf("compass: marshal overrides: %w", err) } diff --git a/modules/compass/persist_test.go b/modules/compass/persist_test.go index e5b5c72..fc848f2 100644 --- a/modules/compass/persist_test.go +++ b/modules/compass/persist_test.go @@ -115,3 +115,44 @@ func TestPersistRejectsInvalidEnvName(t *testing.T) { t.Fatalf("error should not echo unsanitized env path: %v", err) } } + +func TestPersistKeepsEarlierOverrides(t *testing.T) { + dir := writeConfigTree(t, map[string]string{ + "app.yaml": "name: base\n", + "env/development/overrides.yaml": "app:\n name: persisted\n", + }) + cfg, err := Open(Options{Dir: dir, Env: "development", Environ: []string{}}) + if err != nil { + t.Fatalf("Open: %v", err) + } + if err := cfg.Set("push.public_key", "saved-key"); err != nil { + t.Fatalf("Set: %v", err) + } + if err := cfg.Persist(); err != nil { + t.Fatalf("Persist: %v", err) + } + again, err := Open(Options{Dir: dir, Env: "development", Environ: []string{}}) + if err != nil { + t.Fatalf("reopen: %v", err) + } + if got := again.String("push.public_key"); got != "saved-key" { + t.Fatalf("push.public_key = %q, want saved-key", got) + } + if got := again.String("app.name"); got != "persisted" { + t.Fatalf("app.name = %q, want the earlier persisted value", got) + } + // A runtime value replaces the persisted value of the same key. + if err := again.Set("app.name", "runtime"); err != nil { + t.Fatal(err) + } + if err := again.Persist(); err != nil { + t.Fatal(err) + } + third, err := Open(Options{Dir: dir, Env: "development", Environ: []string{}}) + if err != nil { + t.Fatal(err) + } + if third.String("app.name") != "runtime" || third.String("push.public_key") != "saved-key" { + t.Fatalf("after second Persist: app.name=%q push.public_key=%q", third.String("app.name"), third.String("push.public_key")) + } +}