feat(01-02): implement layered compass config

- Merge plugin defaults, sorted YAML, SUMMER_ env, overrides and Set
- Persist atomically to env overrides; Reload clears runtime Set
- Discover HasConfig before Register and print layered hello values
This commit is contained in:
Jakub Zych
2026-09-16 13:17:13 +02:00
parent 255de605b1
commit cc44a1fa49
17 changed files with 633 additions and 21 deletions

View File

@@ -2,62 +2,348 @@ 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"
)
// Config is a YAML configuration tree with dot-path access.
type Config struct {
k *koanf.Koanf
// 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
}
// Load reads config/<section>.yaml files from dir. Each filename stem becomes
// the top-level section, so config/app.yaml key name is app.name.
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 {
return nil, fmt.Errorf("compass: read config dir %s: %w", dir, err)
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("compass: read config dir: %w", err)
}
parser := yaml.Parser()
names := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
section, ok := sectionName(name)
if !ok {
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)
raw, err := os.ReadFile(path)
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 nil, fmt.Errorf("compass: read %s: %w", path, err)
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 nil, fmt.Errorf("compass: parse %s: %w", path, err)
return fmt.Errorf("compass: parse plugin config %s: %w", id, err)
}
if parsed == nil {
parsed = map[string]any{}
}
if err := k.Set(section, parsed); err != nil {
return nil, fmt.Errorf("compass: set section %s: %w", section, err)
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 &Config{k: k}, nil
return nil
}
// String returns the string value at path, or "" if unset.
func (c *Config) String(path string) string {
if c == nil || c.k == nil {
return ""
func pluginMergePath(id, path string) string {
rel := filepath.ToSlash(path)
base := filepath.Base(rel)
section, _ := sectionName(base)
if section == "config" {
return id
}
return c.k.String(path)
return id + "." + section
}
func isOverridesFile(name string) bool {
return name == "overrides.yaml" || name == "overrides.yml"
}
func sectionName(filename string) (string, bool) {

124
compass/env.go Normal file
View 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
}

107
compass/persist.go Normal file
View 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)))
}