refactor(10.2-01): nest framework packages under modules
- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
This commit is contained in:
358
modules/compass/config.go
Normal file
358
modules/compass/config.go
Normal file
@@ -0,0 +1,358 @@
|
||||
package compass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/knadh/koanf/parsers/yaml"
|
||||
"github.com/knadh/koanf/providers/confmap"
|
||||
"github.com/knadh/koanf/providers/file"
|
||||
"github.com/knadh/koanf/v2"
|
||||
)
|
||||
|
||||
// Options controls how configuration is loaded.
|
||||
type Options struct {
|
||||
// Dir is the app config directory, typically "config".
|
||||
Dir string
|
||||
// Env is the environment name. Empty means detect from SUMMER_ENV,
|
||||
// defaulting to "production". An explicit value wins over SUMMER_ENV.
|
||||
Env string
|
||||
// Environ is the KEY=VALUE list used for SUMMER_ variables and
|
||||
// SUMMER_ENV detection. Nil means os.Environ(). A .env file fills
|
||||
// only keys absent from this list and never mutates the process
|
||||
// environment.
|
||||
Environ []string
|
||||
}
|
||||
|
||||
type pluginFS struct {
|
||||
id string
|
||||
fsys fs.FS
|
||||
}
|
||||
|
||||
// Config is a YAML configuration tree with layered overrides and
|
||||
// dot-path access.
|
||||
type Config struct {
|
||||
mu sync.RWMutex
|
||||
opts Options
|
||||
dir string
|
||||
env string
|
||||
environ []string
|
||||
plugins []pluginFS
|
||||
k *koanf.Koanf
|
||||
runtime *koanf.Koanf
|
||||
}
|
||||
|
||||
// Load reads config from dir using SUMMER_ENV (default production).
|
||||
func Load(dir string) (*Config, error) {
|
||||
return Open(Options{Dir: dir})
|
||||
}
|
||||
|
||||
// Open loads configuration using opts.
|
||||
func Open(opts Options) (*Config, error) {
|
||||
if opts.Dir == "" {
|
||||
return nil, fmt.Errorf("compass: config directory is empty")
|
||||
}
|
||||
c := &Config{
|
||||
opts: opts,
|
||||
dir: opts.Dir,
|
||||
runtime: koanf.New("."),
|
||||
}
|
||||
if err := c.refreshSources(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.rebuild(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Environment returns the active environment name.
|
||||
func (c *Config) Environment() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.env
|
||||
}
|
||||
|
||||
// MergePlugin merges an embedded plugin config filesystem at the bare
|
||||
// plugin ID path. config/config.yaml keys become <id>.<key>.
|
||||
func (c *Config) MergePlugin(id string, fsys fs.FS) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("compass: config is nil")
|
||||
}
|
||||
if id == "" {
|
||||
return fmt.Errorf("compass: plugin id is empty")
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
replaced := false
|
||||
for i, p := range c.plugins {
|
||||
if p.id == id {
|
||||
c.plugins[i] = pluginFS{id: id, fsys: fsys}
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
c.plugins = append(c.plugins, pluginFS{id: id, fsys: fsys})
|
||||
}
|
||||
if c.runtime == nil {
|
||||
c.runtime = koanf.New(".")
|
||||
}
|
||||
return c.rebuild()
|
||||
}
|
||||
|
||||
// Lookup returns the value at path and whether it exists.
|
||||
func (c *Config) Lookup(path string) (any, bool) {
|
||||
v := c.view()
|
||||
if v == nil || !v.Exists(path) {
|
||||
return nil, false
|
||||
}
|
||||
return v.Get(path), true
|
||||
}
|
||||
|
||||
// String returns the string value at path, or "" if unset.
|
||||
func (c *Config) String(path string) string {
|
||||
v := c.view()
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return v.String(path)
|
||||
}
|
||||
|
||||
// Int returns the int value at path, or 0 if unset.
|
||||
func (c *Config) Int(path string) int {
|
||||
v := c.view()
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return v.Int(path)
|
||||
}
|
||||
|
||||
// Bool returns the bool value at path, or false if unset.
|
||||
func (c *Config) Bool(path string) bool {
|
||||
v := c.view()
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
return v.Bool(path)
|
||||
}
|
||||
|
||||
// Has reports whether path exists in the merged tree.
|
||||
func (c *Config) Has(path string) bool {
|
||||
v := c.view()
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
return v.Exists(path)
|
||||
}
|
||||
|
||||
// LoadSection unmarshals the map at path into out using `koanf` tags.
|
||||
func (c *Config) LoadSection(path string, out any) error {
|
||||
v := c.view()
|
||||
if v == nil {
|
||||
return fmt.Errorf("compass: config is empty")
|
||||
}
|
||||
return v.UnmarshalWithConf(path, out, koanf.UnmarshalConf{Tag: "koanf"})
|
||||
}
|
||||
|
||||
func (c *Config) view() *koanf.Koanf {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
if c.k == nil {
|
||||
return nil
|
||||
}
|
||||
out := c.k.Copy()
|
||||
if c.runtime != nil {
|
||||
_ = out.Merge(c.runtime)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Config) refreshSources() error {
|
||||
base := c.opts.Environ
|
||||
if c.opts.Environ == nil {
|
||||
base = os.Environ()
|
||||
}
|
||||
dotenv, err := readDotEnv(filepath.Join(filepath.Dir(c.dir), ".env"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.environ = mergeEnviron(base, dotenv)
|
||||
|
||||
envName := c.opts.Env
|
||||
if envName == "" {
|
||||
envName = lookupEnv(c.environ, "SUMMER_ENV")
|
||||
}
|
||||
if envName == "" {
|
||||
envName = "production"
|
||||
}
|
||||
envName, err = sanitizeEnv(envName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.env = envName
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) rebuild() error {
|
||||
k := koanf.New(".")
|
||||
for _, p := range c.plugins {
|
||||
if err := mergePluginFS(k, p.id, p.fsys); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.dir != "" {
|
||||
if err := loadSortedSections(k, c.dir, false); err != nil {
|
||||
return err
|
||||
}
|
||||
envDir := filepath.Join(c.dir, "env", c.env)
|
||||
if err := loadSortedSections(k, envDir, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := loadEnvVars(k, c.environ); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.dir != "" && c.env != "" {
|
||||
overrides := filepath.Join(c.dir, "env", c.env, "overrides.yaml")
|
||||
if err := loadOverrides(k, overrides); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
c.k = k
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadSortedSections(k *koanf.Koanf, dir string, skipOverrides bool) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("compass: read config dir: %w", err)
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if skipOverrides && isOverridesFile(name) {
|
||||
continue
|
||||
}
|
||||
if _, ok := sectionName(name); !ok {
|
||||
continue
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
parser := yaml.Parser()
|
||||
for _, name := range names {
|
||||
section, _ := sectionName(name)
|
||||
path := filepath.Join(dir, name)
|
||||
tmp := koanf.New(".")
|
||||
if err := tmp.Load(file.Provider(path), parser); err != nil {
|
||||
return fmt.Errorf("compass: parse %s: %w", filepath.Base(path), err)
|
||||
}
|
||||
if err := k.MergeAt(tmp, section); err != nil {
|
||||
return fmt.Errorf("compass: merge %s: %w", filepath.Base(path), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadOverrides(k *koanf.Koanf, path string) error {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("compass: stat overrides: %w", err)
|
||||
}
|
||||
if err := k.Load(file.Provider(path), yaml.Parser()); err != nil {
|
||||
return fmt.Errorf("compass: parse overrides: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergePluginFS(k *koanf.Koanf, id string, fsys fs.FS) error {
|
||||
if fsys == nil {
|
||||
return nil
|
||||
}
|
||||
var files []string
|
||||
err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if _, ok := sectionName(filepath.Base(path)); !ok {
|
||||
return nil
|
||||
}
|
||||
files = append(files, path)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("compass: read plugin config %s: %w", id, err)
|
||||
}
|
||||
sort.Strings(files)
|
||||
parser := yaml.Parser()
|
||||
for _, path := range files {
|
||||
raw, err := fs.ReadFile(fsys, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compass: read plugin config %s: %w", id, err)
|
||||
}
|
||||
parsed, err := parser.Unmarshal(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compass: parse plugin config %s: %w", id, err)
|
||||
}
|
||||
if parsed == nil {
|
||||
parsed = map[string]any{}
|
||||
}
|
||||
tmp := koanf.New(".")
|
||||
if err := tmp.Load(confmap.Provider(parsed, ""), nil); err != nil {
|
||||
return fmt.Errorf("compass: load plugin config %s: %w", id, err)
|
||||
}
|
||||
at := pluginMergePath(id, path)
|
||||
if err := k.MergeAt(tmp, at); err != nil {
|
||||
return fmt.Errorf("compass: merge plugin config %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pluginMergePath(id, path string) string {
|
||||
rel := filepath.ToSlash(path)
|
||||
base := filepath.Base(rel)
|
||||
section, _ := sectionName(base)
|
||||
if section == "config" {
|
||||
return id
|
||||
}
|
||||
return id + "." + section
|
||||
}
|
||||
|
||||
func isOverridesFile(name string) bool {
|
||||
return name == "overrides.yaml" || name == "overrides.yml"
|
||||
}
|
||||
|
||||
func sectionName(filename string) (string, bool) {
|
||||
switch {
|
||||
case strings.HasSuffix(filename, ".yaml"):
|
||||
return strings.TrimSuffix(filename, ".yaml"), true
|
||||
case strings.HasSuffix(filename, ".yml"):
|
||||
return strings.TrimSuffix(filename, ".yml"), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
540
modules/compass/config_test.go
Normal file
540
modules/compass/config_test.go
Normal file
@@ -0,0 +1,540 @@
|
||||
package compass
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestLoadStringFromAppSection(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "app.yaml")
|
||||
if err := os.WriteFile(path, []byte("name: hello-app\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if got := cfg.String("app.name"); got != "hello-app" {
|
||||
t.Fatalf("String(app.name) = %q, want hello-app", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenDefaultsEnvToProduction(t *testing.T) {
|
||||
dir := writeConfigTree(t, map[string]string{
|
||||
"app.yaml": "name: base\ndebug: false\n",
|
||||
"env/production/app.yaml": "debug: true\n",
|
||||
"env/development/app.yaml": "name: dev\n",
|
||||
})
|
||||
cfg, err := Open(Options{Dir: dir, Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
if cfg.Environment() != "production" {
|
||||
t.Fatalf("Environment = %q, want production", cfg.Environment())
|
||||
}
|
||||
if got := cfg.String("app.name"); got != "base" {
|
||||
t.Fatalf("name = %q, want base", got)
|
||||
}
|
||||
if !cfg.Bool("app.debug") {
|
||||
t.Fatal("production overlay should set app.debug")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitEnvWinsOverSUMMER_ENV(t *testing.T) {
|
||||
dir := writeConfigTree(t, map[string]string{
|
||||
"app.yaml": "name: base\n",
|
||||
"env/development/app.yaml": "name: from-dev\n",
|
||||
"env/staging/app.yaml": "name: from-staging\n",
|
||||
})
|
||||
cfg, err := Open(Options{
|
||||
Dir: dir,
|
||||
Env: "staging",
|
||||
Environ: []string{"SUMMER_ENV=development"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
if cfg.Environment() != "staging" {
|
||||
t.Fatalf("Environment = %q, want staging", cfg.Environment())
|
||||
}
|
||||
if got := cfg.String("app.name"); got != "from-staging" {
|
||||
t.Fatalf("name = %q, want from-staging", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepMergeKeepsUntouchedNestedKeys(t *testing.T) {
|
||||
dir := writeConfigTree(t, map[string]string{
|
||||
"database.yaml": `
|
||||
host: localhost
|
||||
port: 5432
|
||||
pool:
|
||||
max: 10
|
||||
idle: 2
|
||||
`,
|
||||
"env/development/database.yaml": `
|
||||
host: dev-db
|
||||
pool:
|
||||
max: 20
|
||||
`,
|
||||
})
|
||||
cfg, err := Open(Options{Dir: dir, Env: "development", Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
if got := cfg.String("database.host"); got != "dev-db" {
|
||||
t.Fatalf("host = %q, want dev-db", got)
|
||||
}
|
||||
if got := cfg.Int("database.port"); got != 5432 {
|
||||
t.Fatalf("port = %d, want 5432", got)
|
||||
}
|
||||
if got := cfg.Int("database.pool.max"); got != 20 {
|
||||
t.Fatalf("pool.max = %d, want 20", got)
|
||||
}
|
||||
if got := cfg.Int("database.pool.idle"); got != 2 {
|
||||
t.Fatalf("pool.idle = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePluginConfigAtBarePluginID(t *testing.T) {
|
||||
dir := writeConfigTree(t, map[string]string{
|
||||
"app.yaml": "name: hello-app\n",
|
||||
})
|
||||
cfg, err := Open(Options{Dir: dir, Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
fsys := fstest.MapFS{
|
||||
"config/config.yaml": {Data: []byte("posts_per_page: 10\nshow_author: true\n")},
|
||||
}
|
||||
if err := cfg.MergePlugin("golem15.hello", fsys); err != nil {
|
||||
t.Fatalf("MergePlugin: %v", err)
|
||||
}
|
||||
if got := cfg.Int("golem15.hello.posts_per_page"); got != 10 {
|
||||
t.Fatalf("posts_per_page = %d, want 10", got)
|
||||
}
|
||||
if !cfg.Bool("golem15.hello.show_author") {
|
||||
t.Fatal("show_author should be true")
|
||||
}
|
||||
if !cfg.Has("golem15.hello.posts_per_page") {
|
||||
t.Fatal("Has(golem15.hello.posts_per_page) = false")
|
||||
}
|
||||
got, ok := cfg.Lookup("golem15.hello.posts_per_page")
|
||||
if !ok {
|
||||
t.Fatal("Lookup missing posts_per_page")
|
||||
}
|
||||
switch v := got.(type) {
|
||||
case int:
|
||||
if v != 10 {
|
||||
t.Fatalf("Lookup = %v, want 10", got)
|
||||
}
|
||||
case int64:
|
||||
if v != 10 {
|
||||
t.Fatalf("Lookup = %v, want 10", got)
|
||||
}
|
||||
case uint64:
|
||||
if v != 10 {
|
||||
t.Fatalf("Lookup = %v, want 10", got)
|
||||
}
|
||||
default:
|
||||
// YAML may decode as int or the getter path may return the raw value.
|
||||
if cfg.Int("golem15.hello.posts_per_page") != 10 {
|
||||
t.Fatalf("Lookup type %T value %v", got, got)
|
||||
}
|
||||
}
|
||||
|
||||
var section struct {
|
||||
PostsPerPage int `koanf:"posts_per_page"`
|
||||
ShowAuthor bool `koanf:"show_author"`
|
||||
}
|
||||
if err := cfg.LoadSection("golem15.hello", §ion); err != nil {
|
||||
t.Fatalf("LoadSection: %v", err)
|
||||
}
|
||||
if section.PostsPerPage != 10 || !section.ShowAuthor {
|
||||
t.Fatalf("LoadSection = %+v", section)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvVarsOverridePluginDefaults(t *testing.T) {
|
||||
dir := writeConfigTree(t, map[string]string{
|
||||
"app.yaml": "name: hello-app\n",
|
||||
})
|
||||
cfg, err := Open(Options{
|
||||
Dir: dir,
|
||||
Environ: []string{
|
||||
"SUMMER_GOLEM15__HELLO__POSTS_PER_PAGE=25",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
fsys := fstest.MapFS{
|
||||
"config/config.yaml": {Data: []byte("posts_per_page: 10\n")},
|
||||
}
|
||||
if err := cfg.MergePlugin("golem15.hello", fsys); err != nil {
|
||||
t.Fatalf("MergePlugin: %v", err)
|
||||
}
|
||||
if got := cfg.Int("golem15.hello.posts_per_page"); got != 25 {
|
||||
t.Fatalf("posts_per_page = %d, want 25 (env over plugin default)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriorityRuntimeSetThenPersistThenEnvThenOverlayThenBaseThenPlugin(t *testing.T) {
|
||||
dir := writeConfigTree(t, map[string]string{
|
||||
"app.yaml": "name: base\n",
|
||||
"env/development/app.yaml": "name: overlay\n",
|
||||
"env/development/overrides.yaml": "app:\n name: persisted\n",
|
||||
})
|
||||
cfg, err := Open(Options{
|
||||
Dir: dir,
|
||||
Env: "development",
|
||||
Environ: []string{"SUMMER_APP__NAME=from-env"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
if got := cfg.String("app.name"); got != "persisted" {
|
||||
t.Fatalf("before Set, name = %q, want persisted", got)
|
||||
}
|
||||
if err := cfg.Set("app.name", "runtime"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
if got := cfg.String("app.name"); got != "runtime" {
|
||||
t.Fatalf("after Set, name = %q, want runtime", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortedSectionFilesLoadDeterministically(t *testing.T) {
|
||||
dir := writeConfigTree(t, map[string]string{
|
||||
"b.yaml": "value: b\n",
|
||||
"a.yaml": "value: a\nshared: 1\n",
|
||||
})
|
||||
cfg, err := Open(Options{Dir: dir, Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
if got := cfg.String("a.value"); got != "a" {
|
||||
t.Fatalf("a.value = %q, want a", got)
|
||||
}
|
||||
if got := cfg.String("b.value"); got != "b" {
|
||||
t.Fatalf("b.value = %q, want b", got)
|
||||
}
|
||||
if got := cfg.Int("a.shared"); got != 1 {
|
||||
t.Fatalf("a.shared = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfigTree(t *testing.T, files map[string]string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for name, body := range files {
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestPrecedenceIsolatesEachLayer(t *testing.T) {
|
||||
pluginFS := fstest.MapFS{
|
||||
"config/config.yaml": {Data: []byte("name: plugin\n")},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
plugin bool
|
||||
base bool
|
||||
overlay bool
|
||||
env bool
|
||||
persist bool
|
||||
set bool
|
||||
want string
|
||||
}{
|
||||
{name: "plugin default", plugin: true, want: "plugin"},
|
||||
{name: "base over plugin", plugin: true, base: true, want: "base"},
|
||||
{name: "overlay over base", plugin: true, base: true, overlay: true, want: "overlay"},
|
||||
{name: "env over overlay", plugin: true, base: true, overlay: true, env: true, want: "env"},
|
||||
{name: "persist over env", plugin: true, base: true, overlay: true, env: true, persist: true, want: "persist"},
|
||||
{name: "set over persist", plugin: true, base: true, overlay: true, env: true, persist: true, set: true, want: "set"},
|
||||
{name: "env over base without overlay", plugin: true, base: true, env: true, want: "env"},
|
||||
{name: "persist over overlay without env", plugin: true, base: true, overlay: true, persist: true, want: "persist"},
|
||||
{name: "set only", set: true, want: "set"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
files := map[string]string{}
|
||||
if tt.base {
|
||||
files["app.yaml"] = "name: base\n"
|
||||
} else {
|
||||
files["app.yaml"] = "other: 1\n"
|
||||
}
|
||||
if tt.overlay {
|
||||
files["env/development/app.yaml"] = "name: overlay\n"
|
||||
}
|
||||
if tt.persist {
|
||||
files["env/development/overrides.yaml"] = "app:\n name: persist\n"
|
||||
}
|
||||
dir := writeConfigTree(t, files)
|
||||
environ := []string{}
|
||||
if tt.env {
|
||||
environ = []string{"SUMMER_APP__NAME=env"}
|
||||
}
|
||||
cfg, err := Open(Options{Dir: dir, Env: "development", Environ: environ})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
if tt.plugin {
|
||||
if err := cfg.MergePlugin("app", pluginFS); err != nil {
|
||||
t.Fatalf("MergePlugin: %v", err)
|
||||
}
|
||||
}
|
||||
if tt.set {
|
||||
if err := cfg.Set("app.name", "set"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
}
|
||||
if got := cfg.String("app.name"); got != tt.want {
|
||||
t.Fatalf("app.name = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedYAMLIsRejectedByName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
file string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{name: "base section", file: "app.yaml", body: "name: [unterminated\n", want: "app.yaml"},
|
||||
{name: "env overlay", file: "env/development/app.yaml", body: "name: : :\n[", want: "app.yaml"},
|
||||
{name: "overrides", file: "env/development/overrides.yaml", body: "app: [\n", want: "overrides"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
files := map[string]string{"app.yaml": "name: base\n"}
|
||||
files[tt.file] = tt.body
|
||||
dir := writeConfigTree(t, files)
|
||||
_, err := Open(Options{Dir: dir, Env: "development", Environ: []string{}})
|
||||
if err == nil {
|
||||
t.Fatal("expected malformed YAML error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("error %q should name %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedPluginYAMLIsRejected(t *testing.T) {
|
||||
dir := writeConfigTree(t, map[string]string{"app.yaml": "name: base\n"})
|
||||
cfg, err := Open(Options{Dir: dir, Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
fsys := fstest.MapFS{
|
||||
"config/config.yaml": {Data: []byte("posts_per_page: [\n")},
|
||||
}
|
||||
err = cfg.MergePlugin("golem15.hello", fsys)
|
||||
if err == nil {
|
||||
t.Fatal("expected plugin YAML parse error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "golem15.hello") {
|
||||
t.Fatalf("error should name plugin id, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnakeCaseEnvKeysStayLiteral(t *testing.T) {
|
||||
dir := writeConfigTree(t, map[string]string{"app.yaml": "name: base\n"})
|
||||
cfg, err := Open(Options{
|
||||
Dir: dir,
|
||||
Environ: []string{
|
||||
"SUMMER_APP__FEATURE_FLAG=on",
|
||||
"SUMMER_GOLEM15__HELLO__POSTS_PER_PAGE=7",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
if got := cfg.String("app.feature_flag"); got != "on" {
|
||||
t.Fatalf("feature_flag = %q, want on", got)
|
||||
}
|
||||
if cfg.Has("app.feature.flag") {
|
||||
t.Fatal("single underscore must not split the leaf")
|
||||
}
|
||||
if got := cfg.Int("golem15.hello.posts_per_page"); got != 7 {
|
||||
t.Fatalf("posts_per_page = %d, want 7", got)
|
||||
}
|
||||
if cfg.Has("golem15.hello.posts.per.page") {
|
||||
t.Fatal("snake_case plugin key must stay one leaf")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDotEnvDoesNotOverrideRealEnviron(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "config")
|
||||
if err := os.Mkdir(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: base\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, ".env"), []byte("SUMMER_APP__NAME=from-dotenv\nSUMMER_APP__TITLE=dotenv-title\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := Open(Options{
|
||||
Dir: dir,
|
||||
Environ: []string{"SUMMER_APP__NAME=from-real"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
if got := cfg.String("app.name"); got != "from-real" {
|
||||
t.Fatalf("name = %q, want from-real", got)
|
||||
}
|
||||
if got := cfg.String("app.title"); got != "dotenv-title" {
|
||||
t.Fatalf("title = %q, want dotenv-title", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDottedPluginNamespaceAndTypedSection(t *testing.T) {
|
||||
dir := writeConfigTree(t, map[string]string{
|
||||
"app.yaml": "name: hello-app\n",
|
||||
"golem15.hello.yaml": "from_app: true\n",
|
||||
})
|
||||
cfg, err := Open(Options{Dir: dir, Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
fsys := fstest.MapFS{
|
||||
"config/config.yaml": {Data: []byte("posts_per_page: 10\nshow_author: true\n")},
|
||||
"config/settings.yaml": {Data: []byte("theme: dark\n")},
|
||||
}
|
||||
if err := cfg.MergePlugin("golem15.hello", fsys); err != nil {
|
||||
t.Fatalf("MergePlugin: %v", err)
|
||||
}
|
||||
if got := cfg.Int("golem15.hello.posts_per_page"); got != 10 {
|
||||
t.Fatalf("posts_per_page = %d, want 10", got)
|
||||
}
|
||||
if got := cfg.String("golem15.hello.settings.theme"); got != "dark" {
|
||||
t.Fatalf("settings.theme = %q, want dark", got)
|
||||
}
|
||||
if !cfg.Bool("golem15.hello.from_app") {
|
||||
t.Fatal("app-level dotted plugin namespace file should merge at golem15.hello")
|
||||
}
|
||||
|
||||
var section struct {
|
||||
PostsPerPage int `koanf:"posts_per_page"`
|
||||
ShowAuthor bool `koanf:"show_author"`
|
||||
FromApp bool `koanf:"from_app"`
|
||||
}
|
||||
if err := cfg.LoadSection("golem15.hello", §ion); err != nil {
|
||||
t.Fatalf("LoadSection: %v", err)
|
||||
}
|
||||
if section.PostsPerPage != 10 || !section.ShowAuthor || !section.FromApp {
|
||||
t.Fatalf("LoadSection = %+v", section)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPersistReloadRoundTrip(t *testing.T) {
|
||||
dir := writeConfigTree(t, map[string]string{"app.yaml": "name: base\n"})
|
||||
cfg, err := Open(Options{Dir: dir, Env: "development", Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
if err := cfg.Set("app.name", "runtime"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
if err := cfg.Persist(); err != nil {
|
||||
t.Fatalf("Persist: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(dir, "env", "development", "overrides.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(body), "runtime") {
|
||||
t.Fatalf("overrides missing runtime value: %s", body)
|
||||
}
|
||||
if err := cfg.Set("app.name", "unsaved"); err != nil {
|
||||
t.Fatalf("Set unsaved: %v", err)
|
||||
}
|
||||
if err := cfg.Reload(); err != nil {
|
||||
t.Fatalf("Reload: %v", err)
|
||||
}
|
||||
if got := cfg.String("app.name"); got != "runtime" {
|
||||
t.Fatalf("after Reload, name = %q, want persisted runtime", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentReadsWhileSet(t *testing.T) {
|
||||
dir := writeConfigTree(t, map[string]string{"app.yaml": "name: base\ncount: 1\n"})
|
||||
cfg, err := Open(Options{Dir: dir, Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 16; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 80; j++ {
|
||||
_ = cfg.String("app.name")
|
||||
_ = cfg.Int("app.count")
|
||||
_ = cfg.Bool("app.debug")
|
||||
_ = cfg.Has("app.name")
|
||||
_, _ = cfg.Lookup("app.name")
|
||||
var section struct {
|
||||
Name string `koanf:"name"`
|
||||
}
|
||||
_ = cfg.LoadSection("app", §ion)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 80; j++ {
|
||||
if err := cfg.Set("app.count", j); err != nil {
|
||||
t.Errorf("Set: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
if !cfg.Has("app.name") {
|
||||
t.Fatal("app.name missing after concurrent access")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRejectsEmptyDir(t *testing.T) {
|
||||
_, err := Open(Options{})
|
||||
if err == nil {
|
||||
t.Fatal("expected empty dir error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSUMMER_ENVDoesNotEnterConfigTree(t *testing.T) {
|
||||
dir := writeConfigTree(t, map[string]string{
|
||||
"app.yaml": "name: base\n",
|
||||
"env/development/app.yaml": "name: dev\n",
|
||||
})
|
||||
cfg, err := Open(Options{Dir: dir, Environ: []string{"SUMMER_ENV=development"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
if cfg.Has("env") || cfg.Has("SUMMER_ENV") {
|
||||
t.Fatal("SUMMER_ENV must not become a config key")
|
||||
}
|
||||
if cfg.String("app.name") != "dev" {
|
||||
t.Fatalf("name = %q, want dev", cfg.String("app.name"))
|
||||
}
|
||||
}
|
||||
124
modules/compass/env.go
Normal file
124
modules/compass/env.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package compass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/knadh/koanf/providers/env/v2"
|
||||
"github.com/knadh/koanf/v2"
|
||||
)
|
||||
|
||||
const envPrefix = "SUMMER_"
|
||||
|
||||
func loadEnvVars(k *koanf.Koanf, environ []string) error {
|
||||
if k == nil {
|
||||
return nil
|
||||
}
|
||||
return k.Load(env.Provider(".", env.Opt{
|
||||
Prefix: envPrefix,
|
||||
EnvironFunc: func() []string {
|
||||
if environ == nil {
|
||||
return []string{}
|
||||
}
|
||||
return environ
|
||||
},
|
||||
TransformFunc: transformSummerKey,
|
||||
}), nil)
|
||||
}
|
||||
|
||||
func transformSummerKey(key, value string) (string, any) {
|
||||
if key == "SUMMER_ENV" {
|
||||
return "", nil
|
||||
}
|
||||
rest, ok := strings.CutPrefix(key, envPrefix)
|
||||
if !ok || rest == "" {
|
||||
return "", nil
|
||||
}
|
||||
parts := strings.Split(rest, "__")
|
||||
for i, part := range parts {
|
||||
parts[i] = strings.ToLower(part)
|
||||
}
|
||||
return strings.Join(parts, "."), value
|
||||
}
|
||||
|
||||
func readDotEnv(path string) ([]string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("compass: read .env: %w", err)
|
||||
}
|
||||
var out []string
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if after, ok := strings.CutPrefix(line, "export "); ok {
|
||||
line = strings.TrimSpace(after)
|
||||
}
|
||||
key, val, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
val = strings.TrimSpace(val)
|
||||
if len(val) >= 2 {
|
||||
if (val[0] == '"' && val[len(val)-1] == '"') || (val[0] == '\'' && val[len(val)-1] == '\'') {
|
||||
val = val[1 : len(val)-1]
|
||||
}
|
||||
}
|
||||
out = append(out, key+"="+val)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func mergeEnviron(real, dotenv []string) []string {
|
||||
present := make(map[string]struct{}, len(real))
|
||||
out := make([]string, 0, len(real)+len(dotenv))
|
||||
for _, kv := range real {
|
||||
key, _, _ := strings.Cut(kv, "=")
|
||||
present[key] = struct{}{}
|
||||
out = append(out, kv)
|
||||
}
|
||||
for _, kv := range dotenv {
|
||||
key, _, _ := strings.Cut(kv, "=")
|
||||
if _, ok := present[key]; ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, kv)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func lookupEnv(environ []string, key string) string {
|
||||
for i := len(environ) - 1; i >= 0; i-- {
|
||||
k, v, ok := strings.Cut(environ[i], "=")
|
||||
if ok && k == key {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sanitizeEnv(env string) (string, error) {
|
||||
if env == "" {
|
||||
return "", fmt.Errorf("compass: environment name is empty")
|
||||
}
|
||||
if env == "." || env == ".." || strings.Contains(env, "..") || strings.ContainsAny(env, `/\`) {
|
||||
return "", fmt.Errorf("compass: invalid environment name")
|
||||
}
|
||||
for _, r := range env {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' {
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("compass: invalid environment name")
|
||||
}
|
||||
return env, nil
|
||||
}
|
||||
120
modules/compass/env_test.go
Normal file
120
modules/compass/env_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package compass
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnvDoubleUnderscoreMapsToDotPath(t *testing.T) {
|
||||
dir := writeConfigTree(t, map[string]string{
|
||||
"database.yaml": "host: localhost\n",
|
||||
"app.yaml": "name: base\n",
|
||||
})
|
||||
cfg, err := Open(Options{
|
||||
Dir: dir,
|
||||
Environ: []string{
|
||||
"SUMMER_DATABASE__HOST=from-env",
|
||||
"SUMMER_GOLEM15__HELLO__POSTS_PER_PAGE=25",
|
||||
"IGNORED_VALUE=nope",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
if got := cfg.String("database.host"); got != "from-env" {
|
||||
t.Fatalf("database.host = %q, want from-env", got)
|
||||
}
|
||||
if got := cfg.Int("golem15.hello.posts_per_page"); got != 25 {
|
||||
t.Fatalf("golem15.hello.posts_per_page = %d, want 25", got)
|
||||
}
|
||||
if cfg.Has("ignored_value") || cfg.Has("IGNORED_VALUE") {
|
||||
t.Fatal("non-SUMMER_ variables must not enter config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleUnderscoreRemainsLiteralLeaf(t *testing.T) {
|
||||
dir := writeConfigTree(t, map[string]string{
|
||||
"app.yaml": "name: base\n",
|
||||
})
|
||||
cfg, err := Open(Options{
|
||||
Dir: dir,
|
||||
Environ: []string{
|
||||
"SUMMER_GOLEM15__HELLO__POSTS_PER_PAGE=9",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
if !cfg.Has("golem15.hello.posts_per_page") {
|
||||
t.Fatal("expected golem15.hello.posts_per_page")
|
||||
}
|
||||
if cfg.Has("golem15.hello.posts.per.page") {
|
||||
t.Fatal("single underscores must not become path separators")
|
||||
}
|
||||
if got := cfg.Int("golem15.hello.posts_per_page"); got != 9 {
|
||||
t.Fatalf("posts_per_page = %d, want 9", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDotEnvFillsOnlyAbsentRealEnvKeysAndDoesNotMutateProcess(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "config")
|
||||
if err := os.Mkdir(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: base\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dotenv := "SUMMER_APP__NAME=from-dotenv\nSUMMER_DATABASE__HOST=dotenv-host\n"
|
||||
if err := os.WriteFile(filepath.Join(root, ".env"), []byte(dotenv), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const sentinel = "SUMMER_COMPASS_DOTENV_SENTINEL"
|
||||
if _, ok := os.LookupEnv(sentinel); ok {
|
||||
t.Fatalf("%s already set in process env", sentinel)
|
||||
}
|
||||
|
||||
cfg, err := Open(Options{
|
||||
Dir: dir,
|
||||
Environ: []string{
|
||||
"SUMMER_APP__NAME=from-real",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
if got := cfg.String("app.name"); got != "from-real" {
|
||||
t.Fatalf("app.name = %q, want from-real (real env wins over .env)", got)
|
||||
}
|
||||
if got := cfg.String("database.host"); got != "dotenv-host" {
|
||||
t.Fatalf("database.host = %q, want dotenv-host (.env fills absent keys)", got)
|
||||
}
|
||||
if _, ok := os.LookupEnv("SUMMER_DATABASE__HOST"); ok {
|
||||
t.Fatal("Open must not mutate process environment with .env values")
|
||||
}
|
||||
if _, ok := os.LookupEnv(sentinel); ok {
|
||||
t.Fatal("Open must not mutate process environment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSUMMER_ENVFromProcessSelectsOverlay(t *testing.T) {
|
||||
dir := writeConfigTree(t, map[string]string{
|
||||
"app.yaml": "name: base\n",
|
||||
"env/development/app.yaml": "name: hello-dev\n",
|
||||
})
|
||||
cfg, err := Open(Options{
|
||||
Dir: dir,
|
||||
Environ: []string{"SUMMER_ENV=development"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
if cfg.Environment() != "development" {
|
||||
t.Fatalf("Environment = %q, want development", cfg.Environment())
|
||||
}
|
||||
if got := cfg.String("app.name"); got != "hello-dev" {
|
||||
t.Fatalf("name = %q, want hello-dev", got)
|
||||
}
|
||||
}
|
||||
107
modules/compass/persist.go
Normal file
107
modules/compass/persist.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package compass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/knadh/koanf/parsers/yaml"
|
||||
"github.com/knadh/koanf/v2"
|
||||
)
|
||||
|
||||
// Set stores an in-memory override that wins over every file and env layer.
|
||||
func (c *Config) Set(path string, value any) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("compass: config is nil")
|
||||
}
|
||||
if path == "" {
|
||||
return fmt.Errorf("compass: empty config path")
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.runtime == nil {
|
||||
c.runtime = koanf.New(".")
|
||||
}
|
||||
return c.runtime.Set(path, value)
|
||||
}
|
||||
|
||||
// Persist writes runtime overrides to config/env/<env>/overrides.yaml
|
||||
// using an atomic replace and restrictive file permissions.
|
||||
func (c *Config) Persist() error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("compass: config is nil")
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.dir == "" {
|
||||
return fmt.Errorf("compass: config directory is empty")
|
||||
}
|
||||
env, err := sanitizeEnv(c.env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
destDir, dest, err := overridesPath(c.dir, env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(destDir, 0o700); err != nil {
|
||||
return fmt.Errorf("compass: create overrides dir: %w", err)
|
||||
}
|
||||
raw := []byte("{}\n")
|
||||
if c.runtime != nil {
|
||||
body, err := c.runtime.Marshal(yaml.Parser())
|
||||
if err != nil {
|
||||
return fmt.Errorf("compass: marshal overrides: %w", err)
|
||||
}
|
||||
raw = body
|
||||
}
|
||||
tmp := dest + ".tmp"
|
||||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return fmt.Errorf("compass: write overrides: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, dest); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("compass: persist overrides: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload rebuilds the tree from disk and plugin sources and clears runtime Set.
|
||||
func (c *Config) Reload() error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("compass: config is nil")
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.runtime = koanf.New(".")
|
||||
if err := c.refreshSources(); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.rebuild()
|
||||
}
|
||||
|
||||
func overridesPath(dir, env string) (destDir, dest string, err error) {
|
||||
root, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("compass: resolve config dir: %w", err)
|
||||
}
|
||||
root = filepath.Clean(root)
|
||||
destDir = filepath.Clean(filepath.Join(root, "env", env))
|
||||
if !within(root, destDir) {
|
||||
return "", "", fmt.Errorf("compass: persist path escapes config directory")
|
||||
}
|
||||
dest = filepath.Clean(filepath.Join(destDir, "overrides.yaml"))
|
||||
if !within(destDir, dest) {
|
||||
return "", "", fmt.Errorf("compass: persist path escapes config directory")
|
||||
}
|
||||
return destDir, dest, nil
|
||||
}
|
||||
|
||||
func within(root, path string) bool {
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)))
|
||||
}
|
||||
117
modules/compass/persist_test.go
Normal file
117
modules/compass/persist_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package compass
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestSetWinsOverPersistedOverrides(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 got := cfg.String("app.name"); got != "persisted" {
|
||||
t.Fatalf("loaded persisted name = %q, want persisted", got)
|
||||
}
|
||||
if err := cfg.Set("app.name", "runtime"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
if got := cfg.String("app.name"); got != "runtime" {
|
||||
t.Fatalf("Set should win, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistWritesEnvOverridesAtomicallyWithRestrictivePerms(t *testing.T) {
|
||||
dir := writeConfigTree(t, map[string]string{
|
||||
"app.yaml": "name: base\n",
|
||||
})
|
||||
cfg, err := Open(Options{Dir: dir, Env: "development", Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
if err := cfg.Set("app.name", "saved"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
if err := cfg.Persist(); err != nil {
|
||||
t.Fatalf("Persist: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "env", "development", "overrides.yaml")
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat overrides: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("overrides perm = %o, want 0600", info.Mode().Perm())
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(body), "saved") {
|
||||
t.Fatalf("overrides.yaml = %q, want to contain saved", body)
|
||||
}
|
||||
tmp := filepath.Join(dir, "env", "development", "overrides.yaml.tmp")
|
||||
if _, err := os.Stat(tmp); !os.IsNotExist(err) {
|
||||
t.Fatalf("temporary persist file should not remain: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadClearsSetAndRereadsFiles(t *testing.T) {
|
||||
dir := writeConfigTree(t, map[string]string{
|
||||
"app.yaml": "name: base\n",
|
||||
})
|
||||
cfg, err := Open(Options{Dir: dir, Env: "development", Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
fsys := fstest.MapFS{
|
||||
"config/config.yaml": {Data: []byte("posts_per_page: 10\n")},
|
||||
}
|
||||
if err := cfg.MergePlugin("golem15.hello", fsys); err != nil {
|
||||
t.Fatalf("MergePlugin: %v", err)
|
||||
}
|
||||
if err := cfg.Set("app.name", "runtime"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
if err := cfg.Persist(); err != nil {
|
||||
t.Fatalf("Persist: %v", err)
|
||||
}
|
||||
if err := cfg.Set("app.name", "runtime2"); err != nil {
|
||||
t.Fatalf("Set runtime2: %v", err)
|
||||
}
|
||||
if err := cfg.Set("golem15.hello.posts_per_page", 99); err != nil {
|
||||
t.Fatalf("Set posts: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: disk\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cfg.Reload(); err != nil {
|
||||
t.Fatalf("Reload: %v", err)
|
||||
}
|
||||
if got := cfg.String("app.name"); got != "runtime" {
|
||||
t.Fatalf("after Reload, name = %q, want persisted runtime (Set cleared)", got)
|
||||
}
|
||||
if got := cfg.Int("golem15.hello.posts_per_page"); got != 10 {
|
||||
t.Fatalf("after Reload, posts_per_page = %d, want plugin default 10", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistRejectsInvalidEnvName(t *testing.T) {
|
||||
dir := writeConfigTree(t, map[string]string{
|
||||
"app.yaml": "name: base\n",
|
||||
})
|
||||
_, err := Open(Options{Dir: dir, Env: "../etc", Environ: []string{}})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid environment name to fail")
|
||||
}
|
||||
if strings.Contains(strings.ToLower(err.Error()), "etc") {
|
||||
t.Fatalf("error should not echo unsanitized env path: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user