440 lines
17 KiB
Go
440 lines
17 KiB
Go
package engine
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"testing"
|
|
)
|
|
|
|
type discoveryLoadConfig struct {
|
|
Service struct {
|
|
Settings map[string]string `yaml:"settings"`
|
|
} `yaml:"service"`
|
|
Settings map[string]string `yaml:"settings"`
|
|
Value string `yaml:"value"`
|
|
}
|
|
|
|
func TestSearchLoadMatchesExplicitFileThroughImportsAndContext(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
mainPath := writeImportFile(t, root, "app.yml", `configkit:
|
|
imports: [{path: fragments/extra.yml}]
|
|
settings: {main: selected}
|
|
`)
|
|
importPath := writeImportFile(t, root, "fragments/extra.yml", "settings: {imported: value}\n")
|
|
search := SearchConfig("app", SearchDirectory(root))
|
|
options := Options[discoveryLoadConfig]{
|
|
Imports: true, Filesystem: &FilesystemContext{Root: root}, destination: Keys("service"),
|
|
}
|
|
searched, err := testLoad(search, options)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
explicit, err := testLoad(File(mainPath), options)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertEquivalentDiscoveryLoads(t, searched.Config, searched.Inspection, explicit.Config, explicit.Inspection,
|
|
Keys("service", "settings", "main"), Keys("service", "settings", "imported"))
|
|
|
|
sources := searched.Sources()
|
|
if len(sources) != 2 || sources[0].Kind != SourceKindFile || sources[0].Path != canonicalTestPath(t, mainPath) || sources[1].Path != canonicalTestPath(t, importPath) {
|
|
t.Fatalf("searched source evidence = %#v", sources)
|
|
}
|
|
}
|
|
|
|
func TestSearchLayeredMainAndUnderlayMatchExplicitFiles(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
mainPath := writeImportFile(t, root, "main.yml", "settings: {main: value}\n")
|
|
underlayPath := writeImportFile(t, root, "underlay/base.yml", `configkit:
|
|
imports: [{path: extra.yml}]
|
|
settings: {underlay: value}
|
|
`)
|
|
importPath := writeImportFile(t, root, "underlay/extra.yml", "settings: {imported: value}\n")
|
|
mainSearch := SearchConfig("", SearchDirectory(root, DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("main.yml")}}))
|
|
underlaySearch := SearchConfig("", SearchDirectory(filepath.Join(root, "underlay"), DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("base.yml")}}))
|
|
load := func(main, underlay Source) (*Loaded[discoveryLoadConfig], error) {
|
|
return LoadLayered(
|
|
Input{Source: main, Imports: true}, testSecretBinding(),
|
|
LayeredOptions[discoveryLoadConfig]{Underlays: []Underlay{{Inputs: []Input{{Source: underlay, Imports: true}}}}},
|
|
)
|
|
}
|
|
searched, err := load(mainSearch, underlaySearch)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
explicit, err := load(File(mainPath), File(underlayPath))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertEquivalentDiscoveryLoads(t, searched.Config, searched.Inspection, explicit.Config, explicit.Inspection,
|
|
Keys("settings", "main"), Keys("settings", "underlay"), Keys("settings", "imported"))
|
|
|
|
sources := searched.Inspection.Sources()
|
|
if len(sources) != 3 || sources[0].ID != "source-1" || sources[0].Layer != LayerBase || sources[1].ID != "source-2" || sources[1].Layer != LayerUnderlay || sources[2].Path != canonicalTestPath(t, importPath) || sources[2].UnderlayIndex != 1 {
|
|
t.Fatalf("layered search source evidence = %#v", sources)
|
|
}
|
|
}
|
|
|
|
func TestOptionalSearchExhaustionMatchesNoSource(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
missing := filepath.Join(root, "missing.yml")
|
|
defaults := func() discoveryLoadConfig {
|
|
return discoveryLoadConfig{Value: "default"}
|
|
}
|
|
options := Options[discoveryLoadConfig]{
|
|
Definition: Definition[discoveryLoadConfig]{Defaults: defaults},
|
|
Imports: true, Filesystem: &FilesystemContext{Root: root},
|
|
}
|
|
searched, err := testLoad(SearchConfig("", Paths(missing)), options)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
explicit, err := testLoad(NoSource(), options)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertEquivalentDiscoveryLoads(t, searched.Config, searched.Inspection, explicit.Config, explicit.Inspection, Keys("value"))
|
|
if len(searched.Sources()) != 0 {
|
|
t.Fatalf("optional exhausted search created sources: %#v", searched.Sources())
|
|
}
|
|
}
|
|
|
|
func TestSearchSelectedFileFailuresDoNotFallBack(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("malformed YAML", func(t *testing.T) {
|
|
first := writeImportFile(t, t.TempDir(), "first.yml", ": malformed\n")
|
|
writeImportFile(t, filepath.Dir(first), "later.yml", "value: valid\n")
|
|
loaded, err := testLoad[discoveryLoadConfig](SearchConfig("", Paths(first, filepath.Join(filepath.Dir(first), "later.yml"))), Options[discoveryLoadConfig]{})
|
|
if loaded != nil || err == nil {
|
|
t.Fatalf("malformed selected source = %#v, %v", loaded, err)
|
|
}
|
|
})
|
|
|
|
t.Run("version check", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
first := writeImportFile(t, root, "first.yml", "version: rejected\n")
|
|
writeImportFile(t, root, "later.yml", "version: accepted\n")
|
|
var checked []string
|
|
loaded, err := testLoad[discoveryLoadConfig](SearchConfig("", Paths(first, filepath.Join(root, "later.yml"))), Options[discoveryLoadConfig]{
|
|
SourceVersionMode: sourceVersionModeIfPresent,
|
|
Definition: Definition[discoveryLoadConfig]{Version: &VersionRule{
|
|
Path: Keys("version"),
|
|
Check: func(version Version, _ *Validation) error {
|
|
checked = append(checked, version.Text)
|
|
if version.Text == "rejected" {
|
|
return errors.New("version rejected")
|
|
}
|
|
return nil
|
|
},
|
|
}},
|
|
})
|
|
if loaded != nil || err == nil || !reflect.DeepEqual(checked, []string{"rejected"}) {
|
|
t.Fatalf("version failure search = %#v, %v; checked %#v", loaded, err, checked)
|
|
}
|
|
})
|
|
|
|
t.Run("decode", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
first := writeImportFile(t, root, "first.yml", "value: not-an-integer\n")
|
|
writeImportFile(t, root, "later.yml", "value: 3\n")
|
|
type config struct {
|
|
Value int `yaml:"value"`
|
|
}
|
|
loaded, err := testLoad[config](SearchConfig("", Paths(first, filepath.Join(root, "later.yml"))), Options[config]{})
|
|
if loaded != nil || err == nil {
|
|
t.Fatalf("decode failure search = %#v, %v", loaded, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSearchProbeFailureRetainsFileEvidence(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
main := writeImportFile(t, root, "main.yml", "value: main\n")
|
|
underlay := writeImportFile(t, root, "underlay.yml", "settings: {first: underlay}\n")
|
|
bad := filepath.Join(main, "child.yml")
|
|
later := writeImportFile(t, root, "later.yml", "value: later\n")
|
|
missing := filepath.Join(root, "missing.yml")
|
|
for _, entry := range []struct {
|
|
name string
|
|
id string
|
|
load func(Source) (*Loaded[discoveryLoadConfig], error)
|
|
}{
|
|
{"ordinary", "source-1", func(source Source) (*Loaded[discoveryLoadConfig], error) {
|
|
return Load(source, testSecretBinding(), Options[discoveryLoadConfig]{destination: Keys("settings")})
|
|
}},
|
|
{"underlay after acquired sources", "source-3", func(source Source) (*Loaded[discoveryLoadConfig], error) {
|
|
return LoadLayered(Input{Source: File(main)}, testSecretBinding(), LayeredOptions[discoveryLoadConfig]{
|
|
Underlays: []Underlay{{Inputs: []Input{
|
|
{Source: File(underlay)}, {Source: source, Destination: Keys("service", "settings")},
|
|
}}},
|
|
})
|
|
}},
|
|
} {
|
|
t.Run(entry.name, func(t *testing.T) {
|
|
_, err := entry.load(File(bad))
|
|
var explicit *LoadError
|
|
if !errors.As(err, &explicit) || len(explicit.Diagnostics()) != 1 {
|
|
t.Fatalf("explicit failed file = %v", err)
|
|
}
|
|
if explicit.Diagnostics()[0].Code == CodeSourceMissing {
|
|
t.Skip("host classifies a non-directory ancestor as absence; controlled probe tests cover access failures")
|
|
}
|
|
result, err := entry.load(SearchConfig("", Paths(missing, bad, later)))
|
|
var searched *LoadError
|
|
if result != nil || !errors.As(err, &searched) {
|
|
t.Fatalf("failed search = %#v, %v", result, err)
|
|
}
|
|
if !reflect.DeepEqual(searched.Sources(), explicit.Sources()) || !reflect.DeepEqual(searched.Diagnostics(), explicit.Diagnostics()) {
|
|
t.Fatalf("search failure differs from explicit file:\nsources %#v / %#v\ndiagnostics %#v / %#v", searched.Sources(), explicit.Sources(), searched.Diagnostics(), explicit.Diagnostics())
|
|
}
|
|
primary := searched.Diagnostics()[0].Primary
|
|
if primary == nil || primary.SourceID != entry.id {
|
|
t.Fatalf("failed candidate location = %#v, want %s", primary, entry.id)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSearchValidationPrecedesCandidateProbing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
first := writeImportFile(t, root, "first.yml", "value: selected\n")
|
|
_, err := testLoad[discoveryLoadConfig](SearchConfig("", Searches(Paths(first), Paths(""))), Options[discoveryLoadConfig]{})
|
|
var loadErr *LoadError
|
|
if !errors.As(err, &loadErr) {
|
|
t.Fatalf("invalid later search error = %v, want *LoadError", err)
|
|
}
|
|
if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != CodeOptionsInvalid {
|
|
t.Fatalf("invalid later search component diagnostics = %#v", loadErr.Diagnostics())
|
|
}
|
|
|
|
malformed := writeImportFile(t, t.TempDir(), "main.yml", ": malformed\n")
|
|
loaded, err := LoadLayered(
|
|
Input{Source: SearchConfig("", Paths(malformed))}, testSecretBinding(),
|
|
LayeredOptions[discoveryLoadConfig]{Underlays: []Underlay{{Inputs: []Input{{Source: SearchConfig("", Paths(""))}}}}},
|
|
)
|
|
if loaded != nil || !errors.As(err, &loadErr) {
|
|
t.Fatalf("invalid later underlay search = %#v, %v", loaded, err)
|
|
}
|
|
for _, diagnostic := range loadErr.Diagnostics() {
|
|
if diagnostic.Code != CodeOptionsInvalid {
|
|
t.Fatalf("layered validation acquired main before rejecting underlay: %#v", loadErr.Diagnostics())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSearchContextCompatibilityUsesSelectedOutcome(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
first := writeImportFile(t, root, "first.yml", "value: first\n")
|
|
writeImportFile(t, root, "later.yml", "value: later\n")
|
|
search := SearchConfig("", Paths(first, filepath.Join(root, "later.yml")))
|
|
options := Options[discoveryLoadConfig]{Filesystem: &FilesystemContext{BaseDir: root, Root: root}}
|
|
_, err := testLoad[discoveryLoadConfig](search, options)
|
|
var loadErr *LoadError
|
|
if !errors.As(err, &loadErr) {
|
|
t.Fatalf("selected file with explicit base error = %v, want *LoadError", err)
|
|
}
|
|
if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != CodeOptionsInvalid {
|
|
t.Fatalf("selected file context diagnostics = %#v", loadErr.Diagnostics())
|
|
}
|
|
}
|
|
|
|
func TestSearchEmptyContextUsesSelectedOutcome(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
selected := writeImportFile(t, root, "selected.yml", "value: selected\n")
|
|
missing := filepath.Join(root, "missing.yml")
|
|
context := &FilesystemContext{}
|
|
for _, entry := range []struct {
|
|
name string
|
|
load func(Source) (*Loaded[discoveryLoadConfig], error)
|
|
}{
|
|
{"ordinary", func(source Source) (*Loaded[discoveryLoadConfig], error) {
|
|
return Load(source, testSecretBinding(), Options[discoveryLoadConfig]{Imports: true, Filesystem: context})
|
|
}},
|
|
{"layered main", func(source Source) (*Loaded[discoveryLoadConfig], error) {
|
|
return LoadLayered(Input{Source: source, Imports: true, Filesystem: context}, testSecretBinding(), LayeredOptions[discoveryLoadConfig]{})
|
|
}},
|
|
{"underlay", func(source Source) (*Loaded[discoveryLoadConfig], error) {
|
|
return LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[discoveryLoadConfig]{
|
|
Underlays: []Underlay{{Inputs: []Input{{Source: source, Imports: true, Filesystem: context}}}},
|
|
})
|
|
}},
|
|
} {
|
|
t.Run(entry.name, func(t *testing.T) {
|
|
explicit, err := entry.load(File(selected))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
searched, err := entry.load(SearchConfig("", Paths(missing, selected)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertEquivalentDiscoveryLoads(t, searched.Config, searched.Inspection, explicit.Config, explicit.Inspection, Keys("value"))
|
|
if searched.Config.Value != "selected" {
|
|
t.Fatalf("selected value = %q", searched.Config.Value)
|
|
}
|
|
for _, source := range []Source{NoSource(), SearchConfig("", Paths(missing))} {
|
|
result, err := entry.load(source)
|
|
var failure *LoadError
|
|
if result != nil || !errors.As(err, &failure) || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != CodeOptionsInvalid {
|
|
t.Fatalf("absent input with empty context = %#v, %v", result, err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSearchSourceObservesFileChangesBetweenLoads(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
file := writeImportFile(t, root, "config.yml", "value: first\n")
|
|
source := SearchConfig("", SearchDirectory(root, DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("config.yml")}}))
|
|
load := func() string {
|
|
loaded, err := testLoad[discoveryLoadConfig](source, Options[discoveryLoadConfig]{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return loaded.Config.Value
|
|
}
|
|
if value := load(); value != "first" {
|
|
t.Fatalf("first search value = %q", value)
|
|
}
|
|
if err := os.WriteFile(file, []byte("value: second\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if value := load(); value != "second" {
|
|
t.Fatalf("updated search value = %q", value)
|
|
}
|
|
}
|
|
|
|
func TestSearchSourceObservesUserConfigEnvironmentChanges(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Setenv("APPDATA", filepath.Join(t.TempDir(), "first"))
|
|
} else {
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "first"))
|
|
}
|
|
type config struct {
|
|
Value string `yaml:"value"`
|
|
}
|
|
firstBase := os.Getenv("XDG_CONFIG_HOME")
|
|
if runtime.GOOS == "windows" {
|
|
firstBase = os.Getenv("APPDATA")
|
|
}
|
|
first := writeImportFile(t, firstBase, "app/app.yml", "value: first\n")
|
|
secondBase := filepath.Join(t.TempDir(), "second")
|
|
second := writeImportFile(t, secondBase, "app/app.yml", "value: second\n")
|
|
source := SearchConfig("app", UserConfigDirectory())
|
|
load := func() string {
|
|
loaded, err := testLoad[config](source, Options[config]{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return loaded.Config.Value
|
|
}
|
|
if value := load(); value != "first" {
|
|
t.Fatalf("first user-config search value = %q (%s)", value, first)
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
t.Setenv("APPDATA", secondBase)
|
|
} else {
|
|
t.Setenv("XDG_CONFIG_HOME", secondBase)
|
|
}
|
|
if value := load(); value != "second" {
|
|
t.Fatalf("updated user-config search value = %q (%s)", value, second)
|
|
}
|
|
|
|
// A reached dynamic base failure is terminal, but an earlier match must
|
|
// prevent the invalid environment value from being resolved at all.
|
|
direct := writeImportFile(t, t.TempDir(), "direct.yml", "value: direct\n")
|
|
if runtime.GOOS == "windows" {
|
|
t.Setenv("APPDATA", "relative")
|
|
} else {
|
|
t.Setenv("XDG_CONFIG_HOME", "relative")
|
|
}
|
|
early := SearchConfig("app", Searches(Paths(direct), UserConfigDirectory()))
|
|
loaded, err := testLoad[config](early, Options[config]{})
|
|
if err != nil || loaded.Config.Value != "direct" {
|
|
t.Fatalf("early match with unavailable later base = %#v, %v", loaded, err)
|
|
}
|
|
}
|
|
|
|
func TestSearchSourceObservesWorkingDirectoryChanges(t *testing.T) {
|
|
original, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := os.Chdir(original); err != nil {
|
|
t.Errorf("restore working directory: %v", err)
|
|
}
|
|
})
|
|
firstDir := t.TempDir()
|
|
secondDir := t.TempDir()
|
|
writeImportFile(t, firstDir, "app.yml", "value: first\n")
|
|
writeImportFile(t, secondDir, "app.yml", "value: second\n")
|
|
source := SearchConfig("app", WorkingDirectory())
|
|
load := func() string {
|
|
loaded, err := testLoad[discoveryLoadConfig](source, Options[discoveryLoadConfig]{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return loaded.Config.Value
|
|
}
|
|
if err := os.Chdir(firstDir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if value := load(); value != "first" {
|
|
t.Fatalf("first working-directory search value = %q", value)
|
|
}
|
|
if err := os.Chdir(secondDir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if value := load(); value != "second" {
|
|
t.Fatalf("updated working-directory search value = %q", value)
|
|
}
|
|
}
|
|
|
|
func assertEquivalentDiscoveryLoads[T any](t *testing.T, leftConfig T, left *Inspection, rightConfig T, right *Inspection, presencePaths ...Path) {
|
|
t.Helper()
|
|
if !reflect.DeepEqual(leftConfig, rightConfig) {
|
|
t.Fatalf("search config %#v differs from explicit file config %#v", leftConfig, rightConfig)
|
|
}
|
|
leftSources, rightSources := left.Sources(), right.Sources()
|
|
if !reflect.DeepEqual(leftSources, rightSources) {
|
|
t.Fatalf("search sources %#v differ from explicit file sources %#v", leftSources, rightSources)
|
|
}
|
|
leftMetadata, rightMetadata := left.Metadata(), right.Metadata()
|
|
for _, path := range presencePaths {
|
|
leftInfo, leftPresent := leftMetadata.Lookup(path)
|
|
rightInfo, rightPresent := rightMetadata.Lookup(path)
|
|
if leftPresent != rightPresent || !reflect.DeepEqual(leftInfo, rightInfo) {
|
|
t.Fatalf("search metadata at %s = %#v, %v; explicit metadata = %#v, %v", path.String(), leftInfo, leftPresent, rightInfo, rightPresent)
|
|
}
|
|
}
|
|
for _, source := range leftSources {
|
|
leftContext, leftOK := left.SourceContext(source.ID)
|
|
rightContext, rightOK := right.SourceContext(source.ID)
|
|
if leftOK != rightOK || leftContext.BaseDir != rightContext.BaseDir || (leftContext.FS != nil) != (rightContext.FS != nil) {
|
|
t.Fatalf("search context for %s = %#v, %v; explicit context = %#v, %v", source.ID, leftContext, leftOK, rightContext, rightOK)
|
|
}
|
|
}
|
|
}
|