Provide passive per-source resource contexts
This commit is contained in:
221
facade_resource_context_test.go
Normal file
221
facade_resource_context_test.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user