Activate sibling inheritance in configuration loading
This commit is contained in:
@@ -49,10 +49,30 @@ func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
|
|||||||
return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom)}
|
return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom)}
|
||||||
}
|
}
|
||||||
func fromEngineOrigin(value engine.Origin) Origin {
|
func fromEngineOrigin(value engine.Origin) Origin {
|
||||||
return Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
|
result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
|
||||||
|
if value.Inheritance != nil {
|
||||||
|
result.Inheritance = make([]InheritanceStep, len(value.Inheritance))
|
||||||
|
for index, step := range value.Inheritance {
|
||||||
|
result.Inheritance[index] = InheritanceStep{
|
||||||
|
From: fromEnginePath(step.From), To: fromEnginePath(step.To), Reference: fromEnginePath(step.Reference),
|
||||||
|
Locations: fromEngineLocations(step.Locations),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
func toEngineOrigin(value Origin) engine.Origin {
|
func toEngineOrigin(value Origin) engine.Origin {
|
||||||
return engine.Origin{Layer: engine.Layer(value.Layer), Locations: toEngineLocations(value.Locations), Operation: engine.ChangeKind(value.Operation)}
|
result := engine.Origin{Layer: engine.Layer(value.Layer), Locations: toEngineLocations(value.Locations), Operation: engine.ChangeKind(value.Operation)}
|
||||||
|
if value.Inheritance != nil {
|
||||||
|
result.Inheritance = make([]engine.InheritanceStep, len(value.Inheritance))
|
||||||
|
for index, step := range value.Inheritance {
|
||||||
|
result.Inheritance[index] = engine.InheritanceStep{
|
||||||
|
From: toEnginePath(step.From), To: toEnginePath(step.To), Reference: toEnginePath(step.Reference),
|
||||||
|
Locations: toEngineLocations(step.Locations),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
func fromEngineDiagnostic(value engine.Diagnostic) Diagnostic {
|
func fromEngineDiagnostic(value engine.Diagnostic) Diagnostic {
|
||||||
return Diagnostic{Code: Code(value.Code), Severity: Severity(value.Severity), Path: fromEnginePath(value.Path), Message: value.Message, Primary: fromEngineLocationPointer(value.Primary), Related: fromEngineLocations(value.Related)}
|
return Diagnostic{Code: Code(value.Code), Severity: Severity(value.Severity), Path: fromEnginePath(value.Path), Message: value.Message, Primary: fromEngineLocationPointer(value.Primary), Related: fromEngineLocations(value.Related)}
|
||||||
|
|||||||
@@ -270,6 +270,14 @@ and the everyday checker.
|
|||||||
|
|
||||||
## Stage 7 — Activate the common lifecycle and facade metadata
|
## Stage 7 — Activate the common lifecycle and facade metadata
|
||||||
|
|
||||||
|
**Completed 2026-09-29.** Ordinary and layered loads now expand assembled
|
||||||
|
input before decoding while retaining original source applications, provenance,
|
||||||
|
history, sensitivity, and resource contexts. Main-owned version paths reject
|
||||||
|
structural changes without rejecting unrelated inheritance. Root records and
|
||||||
|
conversions expose detached transfer evidence; public tests cover precedence,
|
||||||
|
presence, resource context, version timing, cancellation, imports, and secrets.
|
||||||
|
`./scripts/check.sh` and `go test -race ./...` passed.
|
||||||
|
|
||||||
Insert expansion in `executeLoadRequest` after ordinary `applyInputLayers` or
|
Insert expansion in `executeLoadRequest` after ordinary `applyInputLayers` or
|
||||||
layered `assembleLayeredInput`, before `decodeLoadInput` invokes defaults/decoding.
|
layered `assembleLayeredInput`, before `decodeLoadInput` invokes defaults/decoding.
|
||||||
Include plain single-source loads, creating the minimal composition carrier needed
|
Include plain single-source loads, creating the minimal composition carrier needed
|
||||||
|
|||||||
284
facade_inheritance_test.go
Normal file
284
facade_inheritance_test.go
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io/fs"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type inheritedEntry struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Count int `yaml:"count"`
|
||||||
|
Resource string `yaml:"resource"`
|
||||||
|
Settings map[string]map[string]string `yaml:"settings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type inheritedConfiguration struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
Entries map[string]inheritedEntry `yaml:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeInheritancePreservesPresenceOriginsAndHistory(t *testing.T) {
|
||||||
|
input := []byte(`entries:
|
||||||
|
parent:
|
||||||
|
enabled: false
|
||||||
|
count: 0
|
||||||
|
settings:
|
||||||
|
primary: {value: original}
|
||||||
|
secondary: {extends: primary}
|
||||||
|
child:
|
||||||
|
extends: parent
|
||||||
|
settings:
|
||||||
|
primary: {value: custom}
|
||||||
|
`)
|
||||||
|
defaultRan := false
|
||||||
|
result, err := configkit.Load(configkit.Bytes("config.yml", input), configkit.Options[inheritedConfiguration]{Definition: configkit.Definition[inheritedConfiguration]{
|
||||||
|
Resolve: func(config *inheritedConfiguration, resolution *configkit.Resolution) error {
|
||||||
|
if !resolution.IsExplicit(configkit.Keys("entries", "child", "enabled")) {
|
||||||
|
t.Error("inherited false is not explicit")
|
||||||
|
}
|
||||||
|
info, found := resolution.InputInfo(configkit.Keys("entries", "child", "enabled"))
|
||||||
|
if !found || len(info.Origin.Inheritance) != 1 {
|
||||||
|
t.Errorf("resolver inherited origin = %#v, %v", info, found)
|
||||||
|
}
|
||||||
|
child := config.Entries["child"]
|
||||||
|
configkit.Default(resolution, configkit.Keys("entries", "child", "enabled"), &child.Enabled, true, "use fallback")
|
||||||
|
defaultRan = child.Enabled
|
||||||
|
config.Entries["child"] = child
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
child := result.Config.Entries["child"]
|
||||||
|
if defaultRan || child.Enabled || child.Count != 0 || child.Settings["secondary"]["value"] != "custom" {
|
||||||
|
t.Fatalf("inherited values = %#v, default ran %v", child, defaultRan)
|
||||||
|
}
|
||||||
|
path := configkit.Keys("entries", "child", "settings", "secondary", "value")
|
||||||
|
info, found := result.Metadata().Lookup(path)
|
||||||
|
if !found || !info.Explicit || len(info.Origin.Inheritance) != 1 ||
|
||||||
|
info.Origin.Inheritance[0].From.String() != "/entries/child/settings/primary/value" ||
|
||||||
|
info.Origin.Inheritance[0].To.String() != path.String() {
|
||||||
|
t.Fatalf("nested destination origin = %#v, %v", info, found)
|
||||||
|
}
|
||||||
|
info.Origin.Inheritance[0].Locations[0].SourceID = "mutated"
|
||||||
|
info, _ = result.Metadata().Lookup(path)
|
||||||
|
if info.Origin.Inheritance[0].Locations[0].SourceID == "mutated" {
|
||||||
|
t.Fatal("public inheritance record aliases inspection state")
|
||||||
|
}
|
||||||
|
if _, found := result.Metadata().Lookup(configkit.Keys("entries", "child", "extends")); found {
|
||||||
|
t.Fatal("consumed directive remained effective")
|
||||||
|
}
|
||||||
|
control, found := result.Explain(configkit.Keys("entries", "child", "extends"))
|
||||||
|
if !found || control.Effective.Present || len(control.History) < 2 ||
|
||||||
|
control.History[len(control.History)-1].Origin.Operation != configkit.ChangeInheritance {
|
||||||
|
t.Fatalf("consumed directive = %#v, %v", control, found)
|
||||||
|
}
|
||||||
|
explanation, found := result.Explain(path)
|
||||||
|
if !found || explanation.Effective.YAML != "custom\n" {
|
||||||
|
t.Fatalf("nested explanation = %#v, %v", explanation, found)
|
||||||
|
}
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if change.Origin.Operation == configkit.ChangeDecoding {
|
||||||
|
t.Fatalf("inherited value attributed to decoding: %#v", explanation.History)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeInheritanceLayeredPrecedenceAndSourceContext(t *testing.T) {
|
||||||
|
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"config/main.yml": &fstest.MapFile{Data: []byte("entries: {child: {extends: parent}}\n")},
|
||||||
|
"shared/parent.yml": &fstest.MapFile{Data: []byte("entries: {parent: {resource: icon.txt, count: 1}}\n")},
|
||||||
|
"shared/flags.yml": &fstest.MapFile{Data: []byte("entries: {parent: {enabled: false}}\n")},
|
||||||
|
"shared/icon.txt": &fstest.MapFile{Data: []byte("parent-resource")},
|
||||||
|
"config/overlays/test.yml": &fstest.MapFile{Data: []byte("entries: {parent: {count: 2}}\n")},
|
||||||
|
})
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: filesystem.File("config/main.yml")}, configkit.LayeredOptions[inheritedConfiguration]{
|
||||||
|
Underlays: []configkit.Underlay{
|
||||||
|
{Inputs: []configkit.Input{{Source: filesystem.File("shared/parent.yml")}}},
|
||||||
|
{Inputs: []configkit.Input{{Source: filesystem.File("shared/flags.yml")}}},
|
||||||
|
},
|
||||||
|
Overlay: "test", OverlayFilesystem: &configkit.FilesystemContext{Root: "config", BaseDir: "config", FS: &filesystem},
|
||||||
|
Overrides: []configkit.Override{{Path: configkit.Keys("entries", "child", "count"), Value: 3, Flag: "--count"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
child := result.Config.Entries["child"]
|
||||||
|
if child.Count != 3 || child.Resource != "icon.txt" {
|
||||||
|
t.Fatalf("layered inheritance = %#v", child)
|
||||||
|
}
|
||||||
|
info, found := result.Metadata().Lookup(configkit.Keys("entries", "child", "resource"))
|
||||||
|
if !found || len(info.Origin.Inheritance) != 1 || len(info.Origin.Locations) != 1 {
|
||||||
|
t.Fatalf("resource origin = %#v, %v", info, found)
|
||||||
|
}
|
||||||
|
context, found := result.SourceContext(info.Origin.Locations[0].SourceID)
|
||||||
|
if !found || context.BaseDir != "shared" || context.FS == nil {
|
||||||
|
t.Fatalf("source context = %#v, %v", context, found)
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(context.FS, context.BaseDir+"/"+child.Resource)
|
||||||
|
if err != nil || string(data) != "parent-resource" {
|
||||||
|
t.Fatalf("source-relative resource = %q, %v", data, err)
|
||||||
|
}
|
||||||
|
count, found := result.Metadata().Lookup(configkit.Keys("entries", "child", "count"))
|
||||||
|
if !found || count.Origin.Layer != configkit.LayerCLI || len(count.Origin.Inheritance) != 0 {
|
||||||
|
t.Fatalf("CLI replacement origin = %#v, %v", count, found)
|
||||||
|
}
|
||||||
|
var rendered bytes.Buffer
|
||||||
|
if err := result.WriteEffective(&rendered); err != nil || strings.Contains(rendered.String(), "extends:") {
|
||||||
|
t.Fatalf("effective rendering = %q, %v", rendered.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeInheritanceMappingMatchesBytes(t *testing.T) {
|
||||||
|
values := map[string]any{"entries": map[string]any{
|
||||||
|
"parent": map[string]any{"enabled": false, "count": 0},
|
||||||
|
"child": map[string]any{"extends": "parent"},
|
||||||
|
}}
|
||||||
|
mapped, err := configkit.Load[inheritedConfiguration](configkit.Mapping("mapping", values))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fromYAML, err := configkit.Load[inheritedConfiguration](configkit.Bytes("config.yml", []byte("entries: {parent: {enabled: false, count: 0}, child: {extends: parent}}\n")))
|
||||||
|
if err != nil || !reflect.DeepEqual(mapped.Config, fromYAML.Config) {
|
||||||
|
t.Fatalf("mapping and bytes differ: %#v, %#v, %v", mapped, fromYAML, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeInheritanceProtectsVersionOwnership(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, input string
|
||||||
|
path configkit.Path
|
||||||
|
wantFailure bool
|
||||||
|
}{
|
||||||
|
{"inherited version", "entries: {parent: {count: 1}, child: {extends: parent}}\n", configkit.Keys("entries", "child", "count"), true},
|
||||||
|
{"overridden version", "entries: {parent: {count: 1}, child: {extends: parent, count: 1}}\n", configkit.Keys("entries", "child", "count"), true},
|
||||||
|
{"direct unchanged version", "entries: {parent: {enabled: false}, child: {extends: parent, count: 1}}\n", configkit.Keys("entries", "child", "count"), false},
|
||||||
|
{"consumed checked directive", "entries: {parent: {count: 1}, child: {extends: parent}}\n", configkit.Keys("entries", "child", "extends"), true},
|
||||||
|
{"independent top-level version", "version: v1\nentries: {parent: {count: 1}, child: {extends: parent}}\n", configkit.Keys("version"), false},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
checks := struct{ version, defaults, resolution, validation int }{}
|
||||||
|
result, err := configkit.Load(configkit.Bytes("version.yml", []byte(test.input)), configkit.Options[inheritedConfiguration]{Definition: configkit.Definition[inheritedConfiguration]{
|
||||||
|
Version: &configkit.VersionRule{Path: test.path, Check: func(configkit.Version, *configkit.Validation) error {
|
||||||
|
checks.version++
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
Defaults: func() inheritedConfiguration { checks.defaults++; return inheritedConfiguration{} },
|
||||||
|
Resolve: func(*inheritedConfiguration, *configkit.Resolution) error { checks.resolution++; return nil },
|
||||||
|
Validate: func(*inheritedConfiguration, *configkit.Validation) { checks.validation++ },
|
||||||
|
}})
|
||||||
|
if test.wantFailure {
|
||||||
|
if result != nil || err == nil {
|
||||||
|
t.Fatalf("protected version = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
loadError, ok := err.(*configkit.LoadError)
|
||||||
|
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeInheritanceVersion ||
|
||||||
|
checks != (struct{ version, defaults, resolution, validation int }{version: 1}) {
|
||||||
|
t.Fatalf("version failure = %v, callbacks %#v", err, checks)
|
||||||
|
}
|
||||||
|
} else if err != nil || result == nil || checks != (struct{ version, defaults, resolution, validation int }{1, 1, 1, 1}) {
|
||||||
|
t.Fatalf("unchanged version = %#v, %v, callbacks %#v", result, err, checks)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeInheritanceFailureStopsLaterCallbacks(t *testing.T) {
|
||||||
|
checks := struct{ defaults, resolution, validation int }{}
|
||||||
|
result, err := configkit.Load(configkit.Bytes("invalid.yml", []byte("entries: {child: {extends: PRIVATE-REFERENCE-CANARY}}\n")), configkit.Options[inheritedConfiguration]{Definition: configkit.Definition[inheritedConfiguration]{
|
||||||
|
Defaults: func() inheritedConfiguration { checks.defaults++; return inheritedConfiguration{} },
|
||||||
|
Resolve: func(*inheritedConfiguration, *configkit.Resolution) error { checks.resolution++; return nil },
|
||||||
|
Validate: func(*inheritedConfiguration, *configkit.Validation) { checks.validation++ },
|
||||||
|
}})
|
||||||
|
if result != nil || err == nil || checks != (struct{ defaults, resolution, validation int }{}) || strings.Contains(err.Error(), "PRIVATE-REFERENCE-CANARY") {
|
||||||
|
t.Fatalf("invalid inheritance = %#v, %v, callbacks %#v", result, err, checks)
|
||||||
|
}
|
||||||
|
loadError, ok := err.(*configkit.LoadError)
|
||||||
|
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeInheritanceMissing {
|
||||||
|
t.Fatalf("missing reference diagnostics = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeInheritanceResolvesAfterImportsAndCLIReferenceChange(t *testing.T) {
|
||||||
|
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: parent.yml}]}\nentries: {child: {extends: first}}\n")},
|
||||||
|
"parent.yml": &fstest.MapFile{Data: []byte("entries: {first: {count: 1}, second: {count: 2}}\n")},
|
||||||
|
})
|
||||||
|
result, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[inheritedConfiguration]{
|
||||||
|
Imports: true, Overrides: []configkit.Override{{Path: configkit.Keys("entries", "child", "extends"), Value: "second", Flag: "--extends"}},
|
||||||
|
})
|
||||||
|
if err != nil || result.Config.Entries["child"].Count != 2 {
|
||||||
|
t.Fatalf("import and CLI reference = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
history := result.Metadata().History(configkit.Keys("entries", "child", "extends"))
|
||||||
|
if len(history) < 3 || history[len(history)-1].Origin.Operation != configkit.ChangeInheritance {
|
||||||
|
t.Fatalf("superseded reference history = %#v", history)
|
||||||
|
}
|
||||||
|
conflicting := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: parent.yml}]}\nentries: {child: {extends: first}, first: {count: 3}}\n")},
|
||||||
|
"parent.yml": &fstest.MapFile{Data: []byte("entries: {first: {count: 1}}\n")},
|
||||||
|
})
|
||||||
|
_, err = configkit.Load[inheritedConfiguration](conflicting.File("main.yml"), configkit.Options[inheritedConfiguration]{Imports: true})
|
||||||
|
loadError, ok := err.(*configkit.LoadError)
|
||||||
|
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict {
|
||||||
|
t.Fatalf("strict conflict before expansion = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeInheritanceSelectedOverlayCancelsRelationship(t *testing.T) {
|
||||||
|
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"main.yml": &fstest.MapFile{Data: []byte("entries: {parent: {count: 7}, child: {extends: parent, enabled: false}}\n")},
|
||||||
|
"overlays/test.yml": &fstest.MapFile{Data: []byte("entries: {child: {extends: null}}\n")},
|
||||||
|
})
|
||||||
|
result, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[inheritedConfiguration]{
|
||||||
|
Overlay: "test", Filesystem: &configkit.FilesystemContext{Root: ".", FS: &filesystem},
|
||||||
|
})
|
||||||
|
if err != nil || result.Config.Entries["child"].Count != 0 || result.Config.Entries["child"].Enabled {
|
||||||
|
if loadError, ok := err.(*configkit.LoadError); ok {
|
||||||
|
t.Fatalf("cancelled relationship = %#v, %v, diagnostics %#v", result, err, loadError.Diagnostics())
|
||||||
|
}
|
||||||
|
t.Fatalf("cancelled relationship = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
history := result.Metadata().History(configkit.Keys("entries", "child", "extends"))
|
||||||
|
if len(history) < 3 || history[len(history)-1].Origin.Operation != configkit.ChangeInheritance {
|
||||||
|
t.Fatalf("cancellation history = %#v", history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeInheritedSecretsStayRedactedAfterLookup(t *testing.T) {
|
||||||
|
const canary = "INHERITED-RELEASE-TEST-CREDENTIAL"
|
||||||
|
t.Setenv("CONFIGKIT_INHERITED_TEST_SECRET", canary)
|
||||||
|
type entry struct {
|
||||||
|
Credential configkit.Secret `yaml:"credential"`
|
||||||
|
Plain string `yaml:"plain"`
|
||||||
|
}
|
||||||
|
type configuration struct {
|
||||||
|
Entries map[string]entry `yaml:"entries"`
|
||||||
|
}
|
||||||
|
result, err := configkit.Load[configuration](configkit.Bytes("secret.yml", []byte("entries: {parent: {credential: CONFIGKIT_INHERITED_TEST_SECRET, plain: visible}, child: {extends: parent}}\n")), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
|
||||||
|
Secret: func(path configkit.Path) bool { return path.String() == "/entries/parent/credential" },
|
||||||
|
}})
|
||||||
|
if err != nil || result.Config.Entries["child"].Credential.Value() != canary {
|
||||||
|
t.Fatalf("inherited credential = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
path := configkit.Keys("entries", "child", "credential")
|
||||||
|
explanation, found := result.Explain(path)
|
||||||
|
if !found || !explanation.Effective.Redacted || strings.Contains(explanation.Effective.YAML, canary) {
|
||||||
|
t.Fatalf("credential explanation = %#v, %v", explanation, found)
|
||||||
|
}
|
||||||
|
var rendered bytes.Buffer
|
||||||
|
if err := result.WriteEffective(&rendered); err != nil || strings.Contains(rendered.String(), canary) || !strings.Contains(rendered.String(), "visible") {
|
||||||
|
t.Fatalf("effective secret rendering = %q, %v", rendered.String(), err)
|
||||||
|
}
|
||||||
|
rendered.Reset()
|
||||||
|
if err := result.WriteExplanation(&rendered, path); err != nil || strings.Contains(rendered.String(), canary) {
|
||||||
|
t.Fatalf("historical secret rendering = %q, %v", rendered.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
53
internal/engine/inheritance_load.go
Normal file
53
internal/engine/inheritance_load.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
|
||||||
|
func containsInheritanceDirective(root *document.Node) bool {
|
||||||
|
found := false
|
||||||
|
walkDocument(root, Path{}, func(path Path, _ *document.Node) {
|
||||||
|
if len(path.segments) != 0 && path.segments[len(path.segments)-1].kind == pathSegmentKey &&
|
||||||
|
path.segments[len(path.segments)-1].key == inheritanceKey {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
func inheritanceVersionDiagnostic(rule *VersionRule, expanded *inheritanceScope) *Diagnostic {
|
||||||
|
if rule == nil || expanded == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, operation := range expanded.operations {
|
||||||
|
var location Location
|
||||||
|
var affected bool
|
||||||
|
if operation.control != nil {
|
||||||
|
affected = pathsIntersect(operation.control.path, rule.Path)
|
||||||
|
if len(operation.control.locations) != 0 {
|
||||||
|
location = operation.control.locations[0]
|
||||||
|
}
|
||||||
|
} else if operation.transfer != nil {
|
||||||
|
if relative, within := relativeHistoryPath(operation.transfer.to, rule.Path); within {
|
||||||
|
_, affected = documentAtPath(operation.parent, relative)
|
||||||
|
} else if pathContains(rule.Path, operation.transfer.to) {
|
||||||
|
affected = true
|
||||||
|
}
|
||||||
|
if len(operation.transfer.locations) != 0 {
|
||||||
|
location = operation.transfer.locations[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if affected {
|
||||||
|
return &Diagnostic{
|
||||||
|
Code: CodeInheritanceVersion, Severity: SeverityError, Path: rule.Path.clone(),
|
||||||
|
Message: "inheritance cannot change the schema-version path", Primary: &location,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inheritanceOperations(expanded *inheritanceScope) []inheritanceOperation {
|
||||||
|
if expanded == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return expanded.operations
|
||||||
|
}
|
||||||
@@ -195,6 +195,25 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
|||||||
mergeResourceContexts(&input.contexts, contexts)
|
mergeResourceContexts(&input.contexts, contexts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if input.document != nil && containsInheritanceDirective(input.document) {
|
||||||
|
composition, problems := compositionForInput(input)
|
||||||
|
if len(problems) != 0 {
|
||||||
|
accumulator.add(problems...)
|
||||||
|
return nil, accumulator.loadError()
|
||||||
|
}
|
||||||
|
expanded, problems := expandInheritanceWithOrigins(composition.root, composition.origins, defaultProjectionLimits())
|
||||||
|
if len(problems) != 0 {
|
||||||
|
accumulator.add(problems...)
|
||||||
|
return nil, accumulator.loadError()
|
||||||
|
}
|
||||||
|
if problem := inheritanceVersionDiagnostic(request.definition.Version, expanded); problem != nil {
|
||||||
|
accumulator.add(*problem)
|
||||||
|
return nil, accumulator.loadError()
|
||||||
|
}
|
||||||
|
input.composition = composition
|
||||||
|
input.inheritance = expanded
|
||||||
|
input.document = expanded.root
|
||||||
|
}
|
||||||
|
|
||||||
decoded, diagnostics := decodeLoadInput(schema, request.definition, input)
|
decoded, diagnostics := decodeLoadInput(schema, request.definition, input)
|
||||||
accumulator.add(diagnostics...)
|
accumulator.add(diagnostics...)
|
||||||
@@ -206,7 +225,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
|||||||
if hasErrors(diagnostics) {
|
if hasErrors(diagnostics) {
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
if !historyNodesWithinLimit(decoded.baseline, decoded.final, resolved.final, resolved.applications, resolved.snapshots) {
|
if !historyNodesWithinLimit(decoded.baseline, decoded.final, resolved.final, resolved.applications, resolved.snapshots, input.inheritance) {
|
||||||
accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"})
|
accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"})
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type assembledInput struct {
|
|||||||
parsed parsedSource
|
parsed parsedSource
|
||||||
document *document.Node
|
document *document.Node
|
||||||
composition *composedDocument
|
composition *composedDocument
|
||||||
|
inheritance *inheritanceScope
|
||||||
sources []SourceInfo
|
sources []SourceInfo
|
||||||
contexts map[string]ResourceContext
|
contexts map[string]ResourceContext
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type resolvedLoad[T any] struct {
|
|||||||
final *document.Node
|
final *document.Node
|
||||||
snapshots []resolutionSnapshot
|
snapshots []resolutionSnapshot
|
||||||
applications []historyApplication
|
applications []historyApplication
|
||||||
|
inheritance *inheritanceScope
|
||||||
contexts map[string]ResourceContext
|
contexts map[string]ResourceContext
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +36,9 @@ func decodeLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
|
|||||||
}
|
}
|
||||||
return decodedConfiguration{}, diagnostics
|
return decodedConfiguration{}, diagnostics
|
||||||
}
|
}
|
||||||
if input.composition != nil {
|
if input.inheritance != nil {
|
||||||
|
applyCompositionOrigins(&decoded, input.inheritance.origins)
|
||||||
|
} else if input.composition != nil {
|
||||||
applyCompositionOrigins(&decoded, input.composition.origins)
|
applyCompositionOrigins(&decoded, input.composition.origins)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +49,9 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
|
|||||||
var diagnostics []Diagnostic
|
var diagnostics []Diagnostic
|
||||||
configuration := decoded.value.Interface().(T)
|
configuration := decoded.value.Interface().(T)
|
||||||
metadata := metadataFromDecoded(decoded)
|
metadata := metadataFromDecoded(decoded)
|
||||||
if input.composition != nil {
|
if input.inheritance != nil {
|
||||||
|
applyInheritanceMetadata(&metadata, input.composition.root, input.composition.origins, input.composition.overlays, input.inheritance)
|
||||||
|
} else if input.composition != nil {
|
||||||
applyOverlayMetadata(&metadata, input.composition.overlays)
|
applyOverlayMetadata(&metadata, input.composition.overlays)
|
||||||
}
|
}
|
||||||
final := decoded.final
|
final := decoded.final
|
||||||
@@ -68,7 +73,7 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
|
|||||||
} else if input.document != nil {
|
} else if input.document != nil {
|
||||||
applications = []historyApplication{{layer: LayerBase, value: input.document}}
|
applications = []historyApplication{{layer: LayerBase, value: input.document}}
|
||||||
}
|
}
|
||||||
return resolvedLoad[T]{configuration: configuration, decoded: decoded, metadata: metadata, final: final, snapshots: resolutionSnapshots, applications: applications, contexts: input.contexts}, diagnostics
|
return resolvedLoad[T]{configuration: configuration, decoded: decoded, metadata: metadata, final: final, snapshots: resolutionSnapshots, applications: applications, inheritance: input.inheritance, contexts: input.contexts}, diagnostics
|
||||||
}
|
}
|
||||||
|
|
||||||
func detachLoadConfiguration[T any](schema *schemaDescriptor, configuration T, mode secretCloneMode) (T, *Diagnostic) {
|
func detachLoadConfiguration[T any](schema *schemaDescriptor, configuration T, mode secretCloneMode) (T, *Diagnostic) {
|
||||||
@@ -104,6 +109,7 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
|
|||||||
final: final,
|
final: final,
|
||||||
resolution: resolutionSnapshots,
|
resolution: resolutionSnapshots,
|
||||||
applications: applications,
|
applications: applications,
|
||||||
|
inheritance: inheritanceOperations(resolved.inheritance),
|
||||||
secrets: secrets,
|
secrets: secrets,
|
||||||
secretReferences: cloneSecretReferences(references),
|
secretReferences: cloneSecretReferences(references),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ func (m Metadata) Lookup(path Path) (FieldInfo, bool) {
|
|||||||
return detached, true
|
return detached, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsExplicit reports whether a surviving input contribution explicitly defined
|
// IsExplicit reports whether a surviving input contribution, including one
|
||||||
// path.
|
// inherited from a sibling, explicitly defined path.
|
||||||
func (m Metadata) IsExplicit(path Path) bool {
|
func (m Metadata) IsExplicit(path Path) bool {
|
||||||
info, ok := m.Lookup(path)
|
info, ok := m.Lookup(path)
|
||||||
return ok && info.Explicit
|
return ok && info.Explicit
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ type resolutionResult struct {
|
|||||||
diagnostics []Diagnostic
|
diagnostics []Diagnostic
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsExplicit reports whether surviving input explicitly defined path.
|
// IsExplicit reports whether surviving direct or inherited input defined path.
|
||||||
func (r *Resolution) IsExplicit(path Path) bool {
|
func (r *Resolution) IsExplicit(path Path) bool {
|
||||||
state := r.activeState()
|
state := r.activeState()
|
||||||
if !state.validPath(path) {
|
if !state.validPath(path) {
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ func (r *Result[T]) Diagnostics() []Diagnostic {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsExplicit reports whether surviving input explicitly defined path.
|
// IsExplicit reports whether surviving direct or inherited input defined path.
|
||||||
func (r *Resolution) IsExplicit(path Path) bool {
|
func (r *Resolution) IsExplicit(path Path) bool {
|
||||||
return r.engineContext().IsExplicit(toEnginePath(path))
|
return r.engineContext().IsExplicit(toEnginePath(path))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ func (m Metadata) Lookup(path Path) (FieldInfo, bool) {
|
|||||||
return fromEngineFieldInfo(value), ok
|
return fromEngineFieldInfo(value), ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsExplicit reports whether a surviving input contribution explicitly defined
|
// IsExplicit reports whether a surviving input contribution, including one
|
||||||
// path.
|
// inherited from a sibling, explicitly defined path.
|
||||||
func (m Metadata) IsExplicit(path Path) bool {
|
func (m Metadata) IsExplicit(path Path) bool {
|
||||||
return m.value.IsExplicit(toEnginePath(path))
|
return m.value.IsExplicit(toEnginePath(path))
|
||||||
}
|
}
|
||||||
|
|||||||
36
records.go
36
records.go
@@ -32,7 +32,10 @@ type ChangeKind string
|
|||||||
const (
|
const (
|
||||||
// ChangeDecoding records the built-in transition to canonical typed values.
|
// ChangeDecoding records the built-in transition to canonical typed values.
|
||||||
// It is inspection metadata, not an operation accepted by Resolution.Record.
|
// It is inspection metadata, not an operation accepted by Resolution.Record.
|
||||||
ChangeDecoding ChangeKind = "decoding"
|
ChangeDecoding ChangeKind = "decoding"
|
||||||
|
// ChangeInheritance records structural input expansion. It is inspection
|
||||||
|
// metadata, not an operation accepted by Resolution.Record.
|
||||||
|
ChangeInheritance ChangeKind = "inheritance"
|
||||||
ChangeDerivedDefault ChangeKind = "derived_default"
|
ChangeDerivedDefault ChangeKind = "derived_default"
|
||||||
ChangeNormalization ChangeKind = "normalization"
|
ChangeNormalization ChangeKind = "normalization"
|
||||||
)
|
)
|
||||||
@@ -161,6 +164,11 @@ const (
|
|||||||
CodeOverrideLimitExceeded Code = "override.limit_exceeded"
|
CodeOverrideLimitExceeded Code = "override.limit_exceeded"
|
||||||
|
|
||||||
CodeExplainLimitExceeded Code = "explain.limit_exceeded"
|
CodeExplainLimitExceeded Code = "explain.limit_exceeded"
|
||||||
|
|
||||||
|
CodeInheritanceInvalid Code = "inheritance.invalid"
|
||||||
|
CodeInheritanceMissing Code = "inheritance.missing"
|
||||||
|
CodeInheritanceCycle Code = "inheritance.cycle"
|
||||||
|
CodeInheritanceVersion Code = "inheritance.version"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Location identifies a position in a configuration source. Zero line and column
|
// Location identifies a position in a configuration source. Zero line and column
|
||||||
@@ -197,13 +205,23 @@ type Diagnostic struct {
|
|||||||
Related []Location
|
Related []Location
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InheritanceStep identifies a concrete payload transfer controlled by a
|
||||||
|
// relationship directive. Locations belong to that directive, not its value.
|
||||||
|
type InheritanceStep struct {
|
||||||
|
From Path
|
||||||
|
To Path
|
||||||
|
Reference Path
|
||||||
|
Locations []Location
|
||||||
|
}
|
||||||
|
|
||||||
// Origin describes where an effective definition came from. Multiple locations
|
// Origin describes where an effective definition came from. Multiple locations
|
||||||
// represent a composite origin; resolution origins are synthetic and identify their
|
// represent a composite origin; resolution origins are synthetic and identify their
|
||||||
// operation instead of a file location.
|
// operation instead of a file location.
|
||||||
type Origin struct {
|
type Origin struct {
|
||||||
Layer Layer
|
Layer Layer
|
||||||
Locations []Location
|
Locations []Location
|
||||||
Operation ChangeKind
|
Operation ChangeKind
|
||||||
|
Inheritance []InheritanceStep
|
||||||
}
|
}
|
||||||
|
|
||||||
// FieldInfo describes an effective configuration path.
|
// FieldInfo describes an effective configuration path.
|
||||||
@@ -257,6 +275,16 @@ func cloneSourceInfos(sources []SourceInfo) []SourceInfo {
|
|||||||
|
|
||||||
func cloneOrigin(origin Origin) Origin {
|
func cloneOrigin(origin Origin) Origin {
|
||||||
origin.Locations = cloneLocations(origin.Locations)
|
origin.Locations = cloneLocations(origin.Locations)
|
||||||
|
if origin.Inheritance != nil {
|
||||||
|
steps := make([]InheritanceStep, len(origin.Inheritance))
|
||||||
|
for index, step := range origin.Inheritance {
|
||||||
|
steps[index] = InheritanceStep{
|
||||||
|
From: step.From.clone(), To: step.To.clone(), Reference: step.Reference.clone(),
|
||||||
|
Locations: cloneLocations(step.Locations),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
origin.Inheritance = steps
|
||||||
|
}
|
||||||
return origin
|
return origin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user