- Table-driven compass precedence, malformed YAML, snake_case env and concurrent reads - Party duplicate/missing/cycle, reordered manifest, HasConfig/HasCommands discovery - Isolated backpack services, festival panic payload omission, towel nested isolation
541 lines
15 KiB
Go
541 lines
15 KiB
Go
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"))
|
|
}
|
|
}
|