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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user