298 lines
12 KiB
Go
298 lines
12 KiB
Go
package configkit_test
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
|
)
|
|
|
|
type observedResourceFS struct {
|
|
files fstest.MapFS
|
|
opens int
|
|
closed bool
|
|
}
|
|
|
|
func (f *observedResourceFS) Open(name string) (fs.File, error) {
|
|
f.opens++
|
|
if f.closed {
|
|
return nil, fs.ErrClosed
|
|
}
|
|
return f.files.Open(name)
|
|
}
|
|
|
|
type resourceFacadeConfig struct {
|
|
Native string `yaml:"native"`
|
|
Shared string `yaml:"shared"`
|
|
Host string `yaml:"host"`
|
|
Selected string `yaml:"selected"`
|
|
}
|
|
|
|
func TestFacadeSourceContextsFollowOriginsWithoutQueryIO(t *testing.T) {
|
|
host := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(host, "main.yml"), []byte("host: host.txt\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(host, "host.txt"), []byte("host-v1"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
borrowed := &observedResourceFS{files: fstest.MapFS{
|
|
"bundle/config/defaults.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: ../shared.yml}]}\nnative: icon.txt\n")},
|
|
"bundle/shared.yml": &fstest.MapFile{Data: []byte("shared: shared.txt\n")},
|
|
"bundle/overlays/test.yml": &fstest.MapFile{Data: []byte("selected: overlay.txt\n")},
|
|
"bundle/config/icon.txt": &fstest.MapFile{Data: []byte("icon-v1")},
|
|
"bundle/shared.txt": &fstest.MapFile{Data: []byte("shared-v1")},
|
|
"bundle/overlays/overlay.txt": &fstest.MapFile{Data: []byte("overlay-v1")},
|
|
}}
|
|
namespace := configkit.NewFilesystem("bundle", borrowed)
|
|
var resolverContext configkit.ResourceContext
|
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(filepath.Join(host, "main.yml"))}, configkit.LayeredOptions[resourceFacadeConfig]{
|
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
|
Source: namespace.File("bundle/config/defaults.yml"), Imports: true,
|
|
Filesystem: &configkit.FilesystemContext{Root: "bundle", FS: &namespace},
|
|
}}}},
|
|
Overlay: "test", OverlayFilesystem: &configkit.FilesystemContext{Root: "bundle", BaseDir: "bundle", FS: &namespace},
|
|
Definition: configkit.Definition[resourceFacadeConfig]{Resolve: func(config *resourceFacadeConfig, resolution *configkit.Resolution) error {
|
|
before := borrowed.opens
|
|
var ok bool
|
|
resolverContext, ok = resolution.SourceContext("source-2")
|
|
if !ok || resolverContext.BaseDir != "config" || resolverContext.FS == nil {
|
|
t.Errorf("resolver context = %#v, %v", resolverContext, ok)
|
|
}
|
|
if _, ok := resolution.SourceContext("missing"); ok || borrowed.opens != before {
|
|
t.Error("resolution context query performed I/O or guessed an unknown source")
|
|
}
|
|
return nil
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("load: %v, diagnostics %#v", err, err.(*configkit.LoadError).Diagnostics())
|
|
}
|
|
queriesBefore := borrowed.opens
|
|
for _, test := range []struct {
|
|
field, wantBase, wantData string
|
|
}{
|
|
{"native", "config", "icon-v1"},
|
|
{"shared", ".", "shared-v1"},
|
|
{"selected", "overlays", "overlay-v1"},
|
|
{"host", ".", "host-v1"},
|
|
} {
|
|
origin, ok := result.Metadata().Lookup(configkit.Keys(test.field))
|
|
if !ok || len(origin.Origin.Locations) != 1 {
|
|
t.Fatalf("%s origin = %#v, %v", test.field, origin, ok)
|
|
}
|
|
context, ok := result.SourceContext(origin.Origin.Locations[0].SourceID)
|
|
if !ok || context.FS == nil || context.BaseDir != test.wantBase {
|
|
t.Fatalf("%s context = %#v, %v", test.field, context, ok)
|
|
}
|
|
if borrowed.opens != queriesBefore {
|
|
t.Fatal("result SourceContext query performed filesystem I/O")
|
|
}
|
|
resourceName := path.Join(context.BaseDir, map[string]string{
|
|
"native": result.Config.Native, "shared": result.Config.Shared,
|
|
"selected": result.Config.Selected, "host": result.Config.Host,
|
|
}[test.field])
|
|
data, err := fs.ReadFile(context.FS, resourceName)
|
|
if err != nil || string(data) != test.wantData {
|
|
t.Fatalf("%s resource = %q, %v", test.field, data, err)
|
|
}
|
|
queriesBefore = borrowed.opens
|
|
}
|
|
if _, ok := result.SourceContext(""); ok {
|
|
t.Fatal("empty source ID returned a context")
|
|
}
|
|
var nilResult *configkit.Result[resourceFacadeConfig]
|
|
if _, ok := nilResult.SourceContext("source-2"); ok {
|
|
t.Fatal("nil result returned a context")
|
|
}
|
|
if _, err := fs.ReadFile(resolverContext.FS, "../shared.txt"); !errors.Is(err, fs.ErrInvalid) {
|
|
t.Fatalf("native root escape = %v", err)
|
|
}
|
|
borrowed.files["bundle/config/icon.txt"].Data = []byte("icon-v2")
|
|
data, err := fs.ReadFile(resolverContext.FS, "config/icon.txt")
|
|
if err != nil || string(data) != "icon-v2" {
|
|
t.Fatalf("updated native resource = %q, %v", data, err)
|
|
}
|
|
borrowed.closed = true
|
|
if result.Config.Native != "icon.txt" {
|
|
t.Fatal("closing caller filesystem altered loaded configuration")
|
|
}
|
|
if _, err := fs.ReadFile(resolverContext.FS, "config/icon.txt"); !errors.Is(err, fs.ErrClosed) {
|
|
t.Fatalf("closed caller filesystem error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFacadeHostContextSurvivesConfigurationDeletion(t *testing.T) {
|
|
root := t.TempDir()
|
|
main := filepath.Join(root, "main.yml")
|
|
resource := filepath.Join(root, "asset.txt")
|
|
if err := os.WriteFile(main, []byte("native: asset.txt\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(resource, []byte("first"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := configkit.Load[resourceFacadeConfig](configkit.File(main))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
context, ok := result.SourceContext("source-1")
|
|
if !ok || context.BaseDir != "." {
|
|
t.Fatalf("host context = %#v, %v", context, ok)
|
|
}
|
|
if err := os.Remove(main); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(resource, []byte("second"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, result.Config.Native))
|
|
if err != nil || string(data) != "second" || result.Config.Native != "asset.txt" {
|
|
t.Fatalf("resource after source deletion = %q, %v", data, err)
|
|
}
|
|
if _, err := fs.ReadFile(context.FS, "../asset.txt"); !errors.Is(err, fs.ErrInvalid) {
|
|
t.Fatalf("invalid host path = %v", err)
|
|
}
|
|
if err := os.Remove(resource); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := fs.ReadFile(context.FS, "asset.txt"); !errors.Is(err, fs.ErrNotExist) {
|
|
t.Fatalf("missing later resource = %v", err)
|
|
}
|
|
outside := filepath.Join(t.TempDir(), "outside.txt")
|
|
if err := os.WriteFile(outside, []byte("outside"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(outside, filepath.Join(root, "escape")); err == nil {
|
|
if _, err := fs.ReadFile(context.FS, "escape"); !errors.Is(err, fs.ErrPermission) {
|
|
t.Fatalf("host symlink escape = %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFacadeSyntheticSourceContextsAndContextlessSources(t *testing.T) {
|
|
namespace := configkit.NewFilesystem("bundle", fstest.MapFS{
|
|
"bundle/config/icon.txt": &fstest.MapFile{Data: []byte("icon")},
|
|
})
|
|
context := &configkit.FilesystemContext{Root: "bundle", BaseDir: "bundle/config", FS: &namespace}
|
|
for _, test := range []struct {
|
|
name string
|
|
source configkit.Source
|
|
}{
|
|
{"bytes", configkit.Bytes("memory", []byte("native: icon.txt\n"))},
|
|
{"reader", configkit.Reader("stream", strings.NewReader("native: icon.txt\n"))},
|
|
{"mapping", configkit.Mapping("values", map[string]any{"native": "icon.txt"})},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
result, err := configkit.Load(test.source, configkit.Options[resourceFacadeConfig]{Filesystem: context})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resource, ok := result.SourceContext("source-1")
|
|
if !ok || resource.BaseDir != "config" {
|
|
t.Fatalf("synthetic context = %#v, %v", resource, ok)
|
|
}
|
|
data, err := fs.ReadFile(resource.FS, path.Join(resource.BaseDir, result.Config.Native))
|
|
if err != nil || string(data) != "icon" {
|
|
t.Fatalf("synthetic resource = %q, %v", data, err)
|
|
}
|
|
})
|
|
}
|
|
result, err := configkit.Load[resourceFacadeConfig](configkit.Bytes("plain", []byte("native: none\n")))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := result.SourceContext("source-1"); ok {
|
|
t.Fatal("contextless byte source returned a context")
|
|
}
|
|
result, err = configkit.Load(configkit.NoSource(), configkit.Options[resourceFacadeConfig]{Overrides: []configkit.Override{{Path: configkit.Keys("native"), Value: "from-cli", Flag: "--native"}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := result.SourceContext("source-1"); ok {
|
|
t.Fatal("CLI source returned a context")
|
|
}
|
|
}
|
|
|
|
func TestFacadeRelativeImportDestinationsPreserveNativeResourceContexts(t *testing.T) {
|
|
borrowed := &observedResourceFS{files: fstest.MapFS{
|
|
"bundle/config/main.yml": &fstest.MapFile{Data: []byte("configkit:\n imports:\n - path: entries/parent.yml\n destination: [logical]\n")},
|
|
"bundle/config/entries/parent.yml": &fstest.MapFile{Data: []byte("configkit:\n imports:\n - path: nested/details.yml\n destination: [details]\nresource: icon.txt\n")},
|
|
"bundle/config/entries/nested/details.yml": &fstest.MapFile{Data: []byte("resource: detail.txt\n")},
|
|
"bundle/config/entries/icon.txt": &fstest.MapFile{Data: []byte("parent-resource")},
|
|
"bundle/config/entries/nested/detail.txt": &fstest.MapFile{Data: []byte("nested-resource")},
|
|
}}
|
|
namespace := configkit.NewFilesystem("bundle", borrowed)
|
|
input := configkit.Input{
|
|
Source: namespace.File("bundle/config/main.yml"), Imports: true,
|
|
Filesystem: &configkit.FilesystemContext{Root: "bundle", FS: &namespace},
|
|
}
|
|
load := func() *configkit.Result[map[string]any] {
|
|
t.Helper()
|
|
result, err := configkit.LoadLayered[map[string]any](input)
|
|
if err != nil {
|
|
t.Fatalf("load relative native imports: %v", err)
|
|
}
|
|
return result
|
|
}
|
|
result := load()
|
|
wantDestinations := []string{configkit.Keys().String(), "/logical", "/logical/details"}
|
|
wantPaths := []string{"bundle/config/main.yml", "bundle/config/entries/parent.yml", "bundle/config/entries/nested/details.yml"}
|
|
sources := result.Sources()
|
|
if len(sources) != len(wantPaths) {
|
|
t.Fatalf("sources = %#v", sources)
|
|
}
|
|
for index, source := range sources {
|
|
if source.ID != fmt.Sprintf("source-%d", index+1) || source.Kind != configkit.SourceKindFilesystem || source.Path != wantPaths[index] || source.Destination.String() != wantDestinations[index] {
|
|
t.Errorf("source %d = %#v", index, source)
|
|
}
|
|
if index == 0 {
|
|
if source.ImportedFrom != nil {
|
|
t.Errorf("entry source has import location %#v", source.ImportedFrom)
|
|
}
|
|
} else if source.ImportedFrom == nil || source.ImportedFrom.SourceID != fmt.Sprintf("source-%d", index) || source.ImportedFrom.Line == 0 || source.ImportedFrom.Column == 0 {
|
|
t.Errorf("source %d import location = %#v", index, source.ImportedFrom)
|
|
}
|
|
}
|
|
|
|
for _, test := range []struct {
|
|
field, effectivePath, wantBase, wantResource, wantContents string
|
|
}{
|
|
{"parent resource", "/logical/resource", "config/entries", "icon.txt", "parent-resource"},
|
|
{"nested resource", "/logical/details/resource", "config/entries/nested", "detail.txt", "nested-resource"},
|
|
} {
|
|
info, ok := result.Metadata().Lookup(configkit.Keys(strings.Split(strings.Trim(test.effectivePath, "/"), "/")...))
|
|
if !ok || len(info.Origin.Locations) != 1 {
|
|
t.Fatalf("%s origin = %#v, %v", test.field, info, ok)
|
|
}
|
|
context, ok := result.SourceContext(info.Origin.Locations[0].SourceID)
|
|
if !ok || context.BaseDir != test.wantBase || context.FS == nil {
|
|
t.Fatalf("%s context = %#v, %v", test.field, context, ok)
|
|
}
|
|
contents, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, test.wantResource))
|
|
if err != nil || string(contents) != test.wantContents {
|
|
t.Fatalf("%s opened resource = %q, %v", test.field, contents, err)
|
|
}
|
|
}
|
|
|
|
sources[1].Destination = configkit.Keys("caller", "mutation")
|
|
for query := 0; query < 2; query++ {
|
|
fresh := result.Sources()
|
|
if fresh[1].Destination.String() != "/logical" {
|
|
t.Fatalf("query %d source destination = %q", query, fresh[1].Destination.String())
|
|
}
|
|
}
|
|
second := load()
|
|
if !reflect.DeepEqual(second.Sources(), result.Sources()) {
|
|
t.Fatalf("independent source records differ: %#v vs %#v", second.Sources(), result.Sources())
|
|
}
|
|
}
|