Integrate configuration searches into engine loading
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Configuration Discovery Implementation Plan
|
# Configuration Discovery Implementation Plan
|
||||||
|
|
||||||
Status: in progress. Stages 1, 2, 3, 4, and 5 are complete.
|
Status: in progress. Stages 1, 2, 3, 4, 5, and 6 are complete.
|
||||||
|
|
||||||
Implement the target behavior in [the discovery roadmap](discovery.md). That
|
Implement the target behavior in [the discovery roadmap](discovery.md). That
|
||||||
document owns the selected conventions and OD-1 through OD-5; this plan owns the
|
document owns the selected conventions and OD-1 through OD-5; this plan owns the
|
||||||
@@ -322,7 +322,7 @@ Acceptance: shared absent-main behavior is complete for explicit `NoSource`.
|
|||||||
Run focused overlay/context/load tests, `go test -race ./internal/engine`, and
|
Run focused overlay/context/load tests, `go test -race ./internal/engine`, and
|
||||||
the everyday check. Mark and commit.
|
the everyday check. Mark and commit.
|
||||||
|
|
||||||
## Stage 6 — Integrate Search Resolution Into Engine Loading
|
## Stage 6 — Integrate Search Resolution Into Engine Loading (Complete)
|
||||||
|
|
||||||
1. Add a private discovery source kind/state to engine `Source` and have the
|
1. Add a private discovery source kind/state to engine `Source` and have the
|
||||||
engine `SearchConfig` constructor capture the validated description. Add
|
engine `SearchConfig` constructor capture the validated description. Add
|
||||||
|
|||||||
@@ -34,6 +34,19 @@ type SearchOptions struct {
|
|||||||
Required bool
|
Required bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchConfig creates a source that selects the first matching host file when
|
||||||
|
// consumed. Description and option errors are reported during loading.
|
||||||
|
func SearchConfig(application string, search Search, supplied ...SearchOptions) Source {
|
||||||
|
captured := Search{entries: cloneSearchEntries(search.entries), issues: append([]string(nil), search.issues...)}
|
||||||
|
source := Source{kind: sourceSearch, search: &captured, searchApp: application}
|
||||||
|
if len(supplied) > 1 {
|
||||||
|
source.searchIssues = []string{"SearchConfig accepts at most one options value"}
|
||||||
|
} else if len(supplied) == 1 {
|
||||||
|
source.searchRequired = supplied[0].Required
|
||||||
|
}
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
type filenameGroupKind uint8
|
type filenameGroupKind uint8
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
342
internal/engine/discovery_load_test.go
Normal file
342
internal/engine/discovery_load_test.go
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
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 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 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,13 +77,9 @@ func validateAbsentHostFilesystemContext(context *FilesystemContext) *Diagnostic
|
|||||||
func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic {
|
func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic {
|
||||||
switch source.kind {
|
switch source.kind {
|
||||||
case sourceNone:
|
case sourceNone:
|
||||||
if context.FS != nil {
|
return absentFilesystemOptionDiagnostics(context)
|
||||||
if context.FS.namespace == nil || nilFilesystem(context.FS.namespace.filesystem) || context.BaseDir == "" || context.Root == "" || !fs.ValidPath(context.BaseDir) || !fs.ValidPath(context.Root) {
|
case sourceSearch:
|
||||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "native filesystem context requires a namespace, base, and root directory")}
|
return absentFilesystemOptionDiagnostics(context)
|
||||||
}
|
|
||||||
} else if context.Root == "" || !filepath.IsAbs(context.Root) || context.BaseDir != "" && !filepath.IsAbs(context.BaseDir) {
|
|
||||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "absent-input filesystem root and base directories must be absolute host paths")}
|
|
||||||
}
|
|
||||||
case sourceFile:
|
case sourceFile:
|
||||||
if context.FS != nil {
|
if context.FS != nil {
|
||||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "host file sources cannot use a native filesystem context")}
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "host file sources cannot use a native filesystem context")}
|
||||||
@@ -116,6 +112,17 @@ func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Di
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func absentFilesystemOptionDiagnostics(context *FilesystemContext) []Diagnostic {
|
||||||
|
if context.FS != nil {
|
||||||
|
if context.FS.namespace == nil || nilFilesystem(context.FS.namespace.filesystem) || context.BaseDir == "" || context.Root == "" || !fs.ValidPath(context.BaseDir) || !fs.ValidPath(context.Root) {
|
||||||
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "native filesystem context requires a namespace, base, and root directory")}
|
||||||
|
}
|
||||||
|
} else if context.Root == "" || !filepath.IsAbs(context.Root) || context.BaseDir != "" && !filepath.IsAbs(context.BaseDir) {
|
||||||
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "absent-input filesystem root and base directories must be absolute host paths")}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func nativeContext(source Source, context *FilesystemContext) bool {
|
func nativeContext(source Source, context *FilesystemContext) bool {
|
||||||
return source.kind == sourceFilesystem || context != nil && context.FS != nil
|
return source.kind == sourceFilesystem || context != nil && context.FS != nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
|||||||
}
|
}
|
||||||
accumulator.versionState = versionState
|
accumulator.versionState = versionState
|
||||||
input, diagnostics := assembleBaseInputTrackedWithVersion(request.main.source, mainOptions, request.main.destination, accounting, nil, nil, versionState)
|
input, diagnostics := assembleBaseInputTrackedWithVersion(request.main.source, mainOptions, request.main.destination, accounting, nil, nil, versionState)
|
||||||
|
request.main.source = input.source
|
||||||
accumulator.sources = input.sources
|
accumulator.sources = input.sources
|
||||||
accumulator.add(versionState.diagnostics...)
|
accumulator.add(versionState.diagnostics...)
|
||||||
reportedVersionDiagnostics := len(versionState.diagnostics)
|
reportedVersionDiagnostics := len(versionState.diagnostics)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
// assembledInput is the source/document handoff across schema and version checks.
|
// assembledInput is the source/document handoff across schema and version checks.
|
||||||
type assembledInput struct {
|
type assembledInput struct {
|
||||||
|
source Source
|
||||||
parsed parsedSource
|
parsed parsedSource
|
||||||
document *document.Node
|
document *document.Node
|
||||||
composition *composedDocument
|
composition *composedDocument
|
||||||
@@ -101,20 +102,36 @@ func assembleBaseInputTrackedWithVersion[T any](source Source, options Options[T
|
|||||||
}
|
}
|
||||||
|
|
||||||
func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], destination Path, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState, role sourceVersionRole) (assembledInput, []Diagnostic) {
|
func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], destination Path, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState, role sourceVersionRole) (assembledInput, []Diagnostic) {
|
||||||
|
selectedSource := source
|
||||||
|
if source.kind == sourceSearch {
|
||||||
|
resolved, diagnostics := resolveSearchSource(source, source.search, source.searchApp, source.searchRequired, defaultDiscoveryDirectoryHelpers(), defaultDiscoveryHostProbe())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return assembledInput{source: source, versionState: versionState, accounting: accounting}, diagnostics
|
||||||
|
}
|
||||||
|
selectedSource = resolved
|
||||||
|
if options.Filesystem != nil {
|
||||||
|
if diagnostics := filesystemOptionDiagnostics(selectedSource, options.Filesystem); len(diagnostics) != 0 {
|
||||||
|
return assembledInput{source: selectedSource, versionState: versionState, accounting: accounting}, diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selectedSource.kind == sourceNone && activeOverlay(options.Overlay) && options.Filesystem == nil {
|
||||||
|
return assembledInput{source: selectedSource, versionState: versionState, accounting: accounting}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context")}
|
||||||
|
}
|
||||||
|
}
|
||||||
limits := defaultImportLimits()
|
limits := defaultImportLimits()
|
||||||
parseLimits := defaultParseLimits()
|
parseLimits := defaultParseLimits()
|
||||||
parseLimits.source.remaining = limits.bytes - accounting.bytes
|
parseLimits.source.remaining = limits.bytes - accounting.bytes
|
||||||
parsed, diagnostics := parseSource(source, "source-"+strconv.Itoa(accounting.sources+1), parseLimits)
|
parsed, diagnostics := parseSource(selectedSource, "source-"+strconv.Itoa(accounting.sources+1), parseLimits)
|
||||||
if parsed.source != nil {
|
if parsed.source != nil {
|
||||||
parsed.source.Destination = destination.clone()
|
parsed.source.Destination = destination.clone()
|
||||||
parsed.source.placementMode = options.placementMode
|
parsed.source.placementMode = options.placementMode
|
||||||
}
|
}
|
||||||
input := assembledInput{parsed: parsed, document: parsed.document, sources: sourceInfos(parsed.source), versionState: versionState, accounting: accounting}
|
input := assembledInput{source: selectedSource, parsed: parsed, document: parsed.document, sources: sourceInfos(parsed.source), versionState: versionState, accounting: accounting}
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
input.sources = sourceInfos(parsed.source)
|
input.sources = sourceInfos(parsed.source)
|
||||||
// Mapping value issues already identify application content. Other early
|
// Mapping value issues already identify application content. Other early
|
||||||
// failures describe source syntax or access, not a placed value.
|
// failures describe source syntax or access, not a placed value.
|
||||||
if source.kind == sourceMapping && source.mapIssue != nil && len(destination.segments) != 0 {
|
if selectedSource.kind == sourceMapping && selectedSource.mapIssue != nil && len(destination.segments) != 0 {
|
||||||
for index := range diagnostics {
|
for index := range diagnostics {
|
||||||
relative := diagnostics[index].Path
|
relative := diagnostics[index].Path
|
||||||
segments := clonePathSegments(destination.segments, len(relative.segments))
|
segments := clonePathSegments(destination.segments, len(relative.segments))
|
||||||
@@ -200,11 +217,11 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
|
|||||||
versionState.mainProcessing = &processed
|
versionState.mainProcessing = &processed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if options.Imports && source.kind != sourceNone {
|
if options.Imports && selectedSource.kind != sourceNone {
|
||||||
if options.deferAssembly {
|
if options.deferAssembly {
|
||||||
input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessingDeferred(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy, true)
|
input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessingDeferred(selectedSource, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy, true)
|
||||||
} else {
|
} else {
|
||||||
input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessing(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy)
|
input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessing(selectedSource, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy)
|
||||||
}
|
}
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return input, diagnostics
|
return input, diagnostics
|
||||||
@@ -218,8 +235,8 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
|
|||||||
if versionProcessing != nil {
|
if versionProcessing != nil {
|
||||||
input.document = versionProcessing.payload
|
input.document = versionProcessing.payload
|
||||||
}
|
}
|
||||||
if options.Filesystem != nil && (source.kind == sourceNone || !activeOverlay(options.Overlay)) {
|
if options.Filesystem != nil && (selectedSource.kind == sourceNone || !activeOverlay(options.Overlay)) {
|
||||||
if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil {
|
if diagnostic := validateFilesystemContext(selectedSource, options.Filesystem, parsed); diagnostic != nil {
|
||||||
return input, []Diagnostic{*diagnostic}
|
return input, []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,7 +268,7 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
|
|||||||
if accounting.sources+1 > limits.sources || accounting.bytes+parsed.bytes > limits.bytes || accounting.nodes+rawNodes+placementNodes > limits.nodes {
|
if accounting.sources+1 > limits.sources || accounting.bytes+parsed.bytes > limits.bytes || accounting.nodes+rawNodes+placementNodes > limits.nodes {
|
||||||
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the aggregate input limit")}
|
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the aggregate input limit")}
|
||||||
}
|
}
|
||||||
if diagnostic := registerAcquiredSource(source, options.Filesystem, parsed.source, visited, hostToken); diagnostic != nil {
|
if diagnostic := registerAcquiredSource(selectedSource, options.Filesystem, parsed.source, visited, hostToken); diagnostic != nil {
|
||||||
return input, []Diagnostic{*diagnostic}
|
return input, []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
accounting.sources++
|
accounting.sources++
|
||||||
@@ -259,7 +276,7 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
|
|||||||
accounting.nodes += rawNodes + placementNodes
|
accounting.nodes += rawNodes + placementNodes
|
||||||
}
|
}
|
||||||
if !activeOverlay(options.Overlay) || options.Imports {
|
if !activeOverlay(options.Overlay) || options.Imports {
|
||||||
contexts, diagnostic := contextsForAcquiredSources(source, options.Filesystem, parsed.source, input.sources)
|
contexts, diagnostic := contextsForAcquiredSources(selectedSource, options.Filesystem, parsed.source, input.sources)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
return input, []Diagnostic{*diagnostic}
|
return input, []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
|
|||||||
if options.Filesystem != nil {
|
if options.Filesystem != nil {
|
||||||
diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...)
|
diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...)
|
||||||
}
|
}
|
||||||
|
if source.kind == sourceSearch {
|
||||||
|
for _, issue := range source.searchIssues {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, issue))
|
||||||
|
}
|
||||||
|
if source.search == nil {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "configuration search is invalid"))
|
||||||
|
} else {
|
||||||
|
diagnostics = append(diagnostics, validateSearch(*source.search, source.searchApp)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
if source.kind == sourceNone && activeOverlay(options.Overlay) && options.Filesystem == nil {
|
if source.kind == sourceNone && activeOverlay(options.Overlay) && options.Filesystem == nil {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,17 +21,21 @@ const (
|
|||||||
// Source is an opaque configuration input. Construct sources with File,
|
// Source is an opaque configuration input. Construct sources with File,
|
||||||
// Filesystem.File, Bytes, Reader, or NoSource; the zero value is invalid.
|
// Filesystem.File, Bytes, Reader, or NoSource; the zero value is invalid.
|
||||||
type Source struct {
|
type Source struct {
|
||||||
kind sourceKind
|
kind sourceKind
|
||||||
path string
|
path string
|
||||||
label string
|
label string
|
||||||
namespace *nativeNamespace
|
namespace *nativeNamespace
|
||||||
data []byte
|
data []byte
|
||||||
reader *readerSource
|
reader *readerSource
|
||||||
mapping *document.Node
|
mapping *document.Node
|
||||||
mapIssue *valueIssue
|
mapIssue *valueIssue
|
||||||
mapBytes int64
|
mapBytes int64
|
||||||
mapNodes int
|
mapNodes int
|
||||||
createErr sourceCreationError
|
createErr sourceCreationError
|
||||||
|
search *Search
|
||||||
|
searchApp string
|
||||||
|
searchRequired bool
|
||||||
|
searchIssues []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type sourceKind uint8
|
type sourceKind uint8
|
||||||
@@ -44,6 +48,7 @@ const (
|
|||||||
sourceReader
|
sourceReader
|
||||||
sourceMapping
|
sourceMapping
|
||||||
sourceNone
|
sourceNone
|
||||||
|
sourceSearch
|
||||||
)
|
)
|
||||||
|
|
||||||
type sourceCreationError uint8
|
type sourceCreationError uint8
|
||||||
|
|||||||
Reference in New Issue
Block a user