Bind engine secret handling to facade types

This commit is contained in:
2026-09-12 16:04:31 +00:00
parent 9b4f9ac50e
commit da02a19f2c
28 changed files with 348 additions and 232 deletions

View File

@@ -276,6 +276,30 @@ tests pass; binding instances cannot affect each other. Run
`go test ./internal/engine -run 'Secret|Schema|Decode|Clone|Projection|Validation|Callback'`, `go test ./internal/engine -run 'Secret|Schema|Decode|Clone|Projection|Validation|Callback'`,
`go test -race ./internal/engine`, and the everyday check. `go test -race ./internal/engine`, and the everyday check.
### Completion
Status: complete (2026-09-12).
`internal/engine` now receives a per-operation `SecretBinding` at schema and
load construction. Its exact `reflect.Type`, `Read`, and `Make` functions carry
the facade-owned `SecretState` through decoding, projection, unresolved and
validated cloning, dynamic-value rejection, secret lookup, resolution helpers,
and validator mutation auditing. The binding is retained by each schema only;
there is no package-global registration or engine-owned replacement secret.
Removed the candidate's production `Secret` and `SecretEnv` declarations.
`secret_binding_test.go` provides a local test secret type, fresh binding for
every test load, and exact-type/isolation coverage using a second local secret
type. Existing secret tests continue to cover defaults, pointers and containers,
resolution-selected names, lookup-once behavior, borrowed callback values,
redaction, and validator replacement without credential disclosure. The root
implementation remains unchanged for this migration handoff.
Linux validation with Go 1.26.7 passed the focused secret/schema/decode/clone/
projection/validation/callback suite, `go test -race ./internal/engine`, and
`./scripts/check.sh`. There were no deviations. The next handoff creates the
temporary facade's root-owned value, source, diagnostic, and secret wrappers.
## Stage 4 — Build candidate value, source, and diagnostic wrappers ## Stage 4 — Build candidate value, source, and diagnostic wrappers
Status: not started. Status: not started.

View File

@@ -30,11 +30,11 @@ func TestCallbackAssignmentsAreDetachedAcrossLoads(t *testing.T) {
} }
calls := 0 calls := 0
lookup := func(string) (string, bool) { calls++; return "credential", true } lookup := func(string) (string, bool) { calls++; return "credential", true }
first, err := loadWithSecretLookup(NoSource(), lookup, Options[cfg]{Definition: definition}) first, err := testLoadWithSecretLookup(NoSource(), lookup, Options[cfg]{Definition: definition})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
second, err := loadWithSecretLookup(NoSource(), lookup, Options[cfg]{Definition: definition}) second, err := testLoadWithSecretLookup(NoSource(), lookup, Options[cfg]{Definition: definition})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -128,11 +128,11 @@ func (o *valueOperation) clone(value reflect.Value, descriptor *typeDescriptor,
return result, nil return result, nil
case descriptorSecret: case descriptorSecret:
result := reflect.New(value.Type()).Elem() result := reflect.New(value.Type()).Elem()
reference := value.Interface().(Secret) reference := o.schema.secrets.Read(value)
if o.cloneMode == cloneValidatedSecrets { if o.cloneMode == cloneValidatedSecrets {
result.Set(value) result.Set(value)
} else if reference.specified { } else if reference.Specified {
result.Set(reflect.ValueOf(SecretEnv(reference.EnvName()))) result.Set(o.schema.secrets.Make(SecretState{Name: reference.Name, Specified: true}))
} }
return result, nil return result, nil
case descriptorText: case descriptorText:

View File

@@ -179,7 +179,7 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
if node.Kind != document.String { if node.Kind != document.String {
return typeMismatch(path, node, "secret environment name") return typeMismatch(path, node, "secret environment name")
} }
return reflect.ValueOf(SecretEnv(node.Text)), nil return schema.secrets.Make(SecretState{Name: node.Text, Specified: true}), nil
case descriptorText: case descriptorText:
if node.Kind != document.String { if node.Kind != document.String {
return typeMismatch(path, node, "text string") return typeMismatch(path, node, "text string")

View File

@@ -26,7 +26,7 @@ func TestExplainShowsDefaultBaseAndEffectiveProfileValue(t *testing.T) {
mainPath := writeImportFile(t, directory, "config.yml", "configkit:\n imports: [{path: transcription.yml}]\n") mainPath := writeImportFile(t, directory, "config.yml", "configkit:\n imports: [{path: transcription.yml}]\n")
writeImportFile(t, directory, "transcription.yml", "transcription:\n concurrency: 8\n") writeImportFile(t, directory, "transcription.yml", "transcription:\n concurrency: 8\n")
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), "transcription:\n concurrency: 1\n") writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), "transcription:\n concurrency: 1\n")
result, err := Load[explanationConcurrency](File(mainPath), Options[explanationConcurrency]{ result, err := testLoad[explanationConcurrency](File(mainPath), Options[explanationConcurrency]{
Imports: true, Profile: "testing", Imports: true, Profile: "testing",
Definition: Definition[explanationConcurrency]{Defaults: func() explanationConcurrency { Definition: Definition[explanationConcurrency]{Defaults: func() explanationConcurrency {
value := explanationConcurrency{} value := explanationConcurrency{}
@@ -84,7 +84,7 @@ items: [profile]
labels: {profile: retained} labels: {profile: retained}
dynamic: selected dynamic: selected
`) `)
result, err := loadWithSecretLookup(File(mainPath), func(name string) (string, bool) { result, err := testLoadWithSecretLookup(File(mainPath), func(name string) (string, bool) {
if name != "APP_SECRET" { if name != "APP_SECRET" {
t.Fatalf("secret lookup = %q", name) t.Fatalf("secret lookup = %q", name)
} }
@@ -285,7 +285,7 @@ func TestSecretsBootstrapLoadAndExplanationRemainSeparateAndSafe(t *testing.T) {
type config struct { type config struct {
Credential Secret `yaml:"credential"` Credential Secret `yaml:"credential"`
} }
result, err := Load[config](Bytes("config.yml", []byte("credential: "+name+"\n"))) result, err := testLoad[config](Bytes("config.yml", []byte("credential: "+name+"\n")))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -343,7 +343,7 @@ func TestExplanationReconcilesDefaultsAfterInputReplacement(t *testing.T) {
writeImportFile(t, root, "profiles/p.yml", "m: {b: 2}\n") writeImportFile(t, root, "profiles/p.yml", "m: {b: 2}\n")
opts.Profile = "p" opts.Profile = "p"
} }
result, err := Load(File(main), opts) result, err := testLoad(File(main), opts)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -380,11 +380,11 @@ func TestIntegerVersionInspectionRetainsInputAndReconcilesTypedValue(t *testing.
Defaults: func() config { return config{Version: "4"} }, Defaults: func() config { return config{Version: "4"} },
Version: rule, Version: rule,
}} }}
first, err := Load(Bytes("version.yml", []byte("version: 4\n")), options) first, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), options)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
second, err := Load(Bytes("version.yml", []byte("version: 4\n")), options) second, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), options)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -449,7 +449,7 @@ func TestSensitiveIntegerVersionStaysRedactedWithoutSecretLookup(t *testing.T) {
Version string `yaml:"version"` Version string `yaml:"version"`
} }
lookups := 0 lookups := 0
result, err := loadWithSecretLookup(Bytes("version.yml", []byte("version: 987654321\n")), func(string) (string, bool) { result, err := testLoadWithSecretLookup(Bytes("version.yml", []byte("version: 987654321\n")), func(string) (string, bool) {
lookups++ lookups++
return "CREDENTIAL_CANARY", true return "CREDENTIAL_CANARY", true
}, Options[config]{Definition: Definition[config]{ }, Options[config]{Definition: Definition[config]{
@@ -476,7 +476,7 @@ func TestExplanationReplaysResolutionAdditionsAndOverlappingIntent(t *testing.T)
} }
for _, overlap := range []bool{false, true} { for _, overlap := range []bool{false, true} {
t.Run(fmt.Sprint(overlap), func(t *testing.T) { t.Run(fmt.Sprint(overlap), func(t *testing.T) {
result, err := Load(Bytes("base", []byte("m: {old: 1}\n")), Options[cfg]{Definition: Definition[cfg]{Resolve: func(c *cfg, r *Resolution) error { result, err := testLoad(Bytes("base", []byte("m: {old: 1}\n")), Options[cfg]{Definition: Definition[cfg]{Resolve: func(c *cfg, r *Resolution) error {
c.M["new"] = map[string]any{"child": int64(7)} c.M["new"] = map[string]any{"child": int64(7)}
delete(c.M, "old") delete(c.M, "old")
r.Record(Keys("m", "new"), ChangeDerivedDefault, "add nested", Keys("m", "old")) r.Record(Keys("m", "new"), ChangeDerivedDefault, "add nested", Keys("m", "old"))

View File

@@ -26,7 +26,7 @@ items: {alpha: alpha}
var previous []SourceInfo var previous []SourceInfo
for run := 0; run < 2; run++ { for run := 0; run < 2; run++ {
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true}) result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -68,7 +68,7 @@ items: {root: root}
writeImportFile(t, directory, "conf.d/item-a.yml", "items: {a: a}\n") writeImportFile(t, directory, "conf.d/item-a.yml", "items: {a: a}\n")
writeImportFile(t, directory, "conf.d/question1.yml", "items: {question: question}\n") writeImportFile(t, directory, "conf.d/question1.yml", "items: {question: question}\n")
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true}) result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -79,7 +79,7 @@ items: {root: root}
ignoredPath := writeImportFile(t, directory, "conf.d/not-discovered.yml", "unknown: value\n") ignoredPath := writeImportFile(t, directory, "conf.d/not-discovered.yml", "unknown: value\n")
_ = ignoredPath _ = ignoredPath
plainPath := writeImportFile(t, directory, "plain.yml", "items: {root: only}\n") plainPath := writeImportFile(t, directory, "plain.yml", "items: {root: only}\n")
plain, err := Load[importTestConfig](File(plainPath), Options[importTestConfig]{Imports: true}) plain, err := testLoad[importTestConfig](File(plainPath), Options[importTestConfig]{Imports: true})
if err != nil || len(plain.Sources()) != 1 || plain.Config.Items["root"] != "only" { if err != nil || len(plain.Sources()) != 1 || plain.Config.Items["root"] != "only" {
t.Fatalf("implicit discovery result = %#v, %v", plain, err) t.Fatalf("implicit discovery result = %#v, %v", plain, err)
} }
@@ -116,7 +116,7 @@ func TestLoadGlobImportsRejectInvalidAndUnmatchedPatterns(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
mainPath := writeGlobRoot(t, directory, "empty/*.yml", true) mainPath := writeGlobRoot(t, directory, "empty/*.yml", true)
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true}) result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil || len(result.Sources()) != 1 { if err != nil || len(result.Sources()) != 1 {
t.Fatalf("optional unmatched glob = %#v, %v", result, err) t.Fatalf("optional unmatched glob = %#v, %v", result, err)
} }
@@ -166,7 +166,7 @@ func TestLoadGlobImportsConfineSymlinkDirectories(t *testing.T) {
t.Skipf("symlinks unavailable: %v", err) t.Skipf("symlinks unavailable: %v", err)
} }
mainPath := writeGlobRoot(t, root, "linked/*.yml", false) mainPath := writeGlobRoot(t, root, "linked/*.yml", false)
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true}) result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil || result.Config.Items["linked"] != "linked" { if err != nil || result.Config.Items["linked"] != "linked" {
t.Fatalf("contained directory symlink = %#v, %v", result, err) t.Fatalf("contained directory symlink = %#v, %v", result, err)
} }
@@ -232,7 +232,7 @@ func TestGlobTraversalBoundsNonmatchesAndAcceptedCandidates(t *testing.T) {
func TestFilesystemContextIsValidatedWithoutImportsOrActiveProfile(t *testing.T) { func TestFilesystemContextIsValidatedWithoutImportsOrActiveProfile(t *testing.T) {
directory := t.TempDir() directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "items: {root: root}\n") mainPath := writeImportFile(t, directory, "main.yml", "items: {root: root}\n")
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{ result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{
Profile: "default", Filesystem: &FilesystemContext{Root: directory}, Profile: "default", Filesystem: &FilesystemContext{Root: directory},
}) })
if err != nil || result.Config.Items["root"] != "root" { if err != nil || result.Config.Items["root"] != "root" {
@@ -242,12 +242,12 @@ func TestFilesystemContextIsValidatedWithoutImportsOrActiveProfile(t *testing.T)
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Filesystem: &FilesystemContext{Root: filepath.Join(directory, "missing")}}, CodeImportInvalid) requireLoadCode(t, File(mainPath), Options[importTestConfig]{Filesystem: &FilesystemContext{Root: filepath.Join(directory, "missing")}}, CodeImportInvalid)
input := Bytes("memory.yml", []byte("items: {root: memory}\n")) input := Bytes("memory.yml", []byte("items: {root: memory}\n"))
result, err = Load[importTestConfig](input, Options[importTestConfig]{Filesystem: &FilesystemContext{BaseDir: directory, Root: directory}}) result, err = testLoad[importTestConfig](input, Options[importTestConfig]{Filesystem: &FilesystemContext{BaseDir: directory, Root: directory}})
if err != nil || result.Config.Items["root"] != "memory" { if err != nil || result.Config.Items["root"] != "memory" {
t.Fatalf("byte filesystem context = %#v, %v", result, err) t.Fatalf("byte filesystem context = %#v, %v", result, err)
} }
requireLoadCode(t, Bytes("memory.yml", []byte("{}\n")), Options[importTestConfig]{Filesystem: &FilesystemContext{BaseDir: directory}}, CodeOptionsInvalid) requireLoadCode(t, Bytes("memory.yml", []byte("{}\n")), Options[importTestConfig]{Filesystem: &FilesystemContext{BaseDir: directory}}, CodeOptionsInvalid)
defaultsOnly, err := Load[importTestConfig](NoSource(), Options[importTestConfig]{Profile: "default"}) defaultsOnly, err := testLoad[importTestConfig](NoSource(), Options[importTestConfig]{Profile: "default"})
if err != nil || defaultsOnly == nil { if err != nil || defaultsOnly == nil {
t.Fatalf("default profile with NoSource = %#v, %v", defaultsOnly, err) t.Fatalf("default profile with NoSource = %#v, %v", defaultsOnly, err)
} }
@@ -255,7 +255,7 @@ func TestFilesystemContextIsValidatedWithoutImportsOrActiveProfile(t *testing.T)
if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { if runtime.GOOS == "linux" || runtime.GOOS == "darwin" {
selectedLink := filepath.Join(directory, "selected-link.yml") selectedLink := filepath.Join(directory, "selected-link.yml")
if err := os.Symlink(mainPath, selectedLink); err == nil { if err := os.Symlink(mainPath, selectedLink); err == nil {
linked, err := Load[importTestConfig](File(selectedLink), Options[importTestConfig]{Filesystem: &FilesystemContext{Root: directory}}) linked, err := testLoad[importTestConfig](File(selectedLink), Options[importTestConfig]{Filesystem: &FilesystemContext{Root: directory}})
if err != nil || linked.Sources()[0].Path != canonicalTestPath(t, mainPath) { if err != nil || linked.Sources()[0].Path != canonicalTestPath(t, mainPath) {
t.Fatalf("selected symlink identity = %#v, %v", linked, err) t.Fatalf("selected symlink identity = %#v, %v", linked, err)
} }

View File

@@ -33,7 +33,7 @@ func TestLoadFormerDirectiveKeyIsApplicationData(t *testing.T) {
for _, imports := range []bool{false, true} { for _, imports := range []bool{false, true} {
directory := t.TempDir() directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - path: nonexistent.yml\n") mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - path: nonexistent.yml\n")
result, err := Load[config](File(mainPath), Options[config]{Imports: imports}) result, err := testLoad[config](File(mainPath), Options[config]{Imports: imports})
if err != nil { if err != nil {
t.Fatalf("Imports=%v: %v", imports, err) t.Fatalf("Imports=%v: %v", imports, err)
} }
@@ -61,7 +61,7 @@ settings:
thirdPath := writeImportFile(t, directory, "nested/third.yml", "settings:\n nested: nested\n") thirdPath := writeImportFile(t, directory, "nested/third.yml", "settings:\n nested: nested\n")
secondPath := writeImportFile(t, directory, "second.yml", "settings:\n second: second\n") secondPath := writeImportFile(t, directory, "second.yml", "settings:\n second: second\n")
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true}) result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -163,7 +163,7 @@ func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) {
optional: true optional: true
settings: {root: retained} settings: {root: retained}
`) `)
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true}) result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil || result.Config.Settings.Root != "retained" || len(result.Sources()) != 1 { if err != nil || result.Config.Settings.Root != "retained" || len(result.Sources()) != 1 {
t.Fatalf("optional result = %#v, %v", result, err) t.Fatalf("optional result = %#v, %v", result, err)
} }
@@ -253,7 +253,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
t.Skipf("symlinks unavailable: %v", err) t.Skipf("symlinks unavailable: %v", err)
} }
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: link.yml}]}\n") mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: link.yml}]}\n")
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true}) result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil || result.Config.Settings.First != "linked" || result.Sources()[1].Path != canonicalTestPath(t, target) { if err != nil || result.Config.Settings.First != "linked" || result.Sources()[1].Path != canonicalTestPath(t, target) {
t.Fatalf("symlink result = %#v, %v", result, err) t.Fatalf("symlink result = %#v, %v", result, err)
} }
@@ -303,7 +303,7 @@ func TestLoadByteAndReaderImportsRequireExplicitFilesystemContext(t *testing.T)
{name: "reader URI label", source: func() Source { return Reader("https://example.invalid/main.yml", strings.NewReader(string(input))) }}, {name: "reader URI label", source: func() Source { return Reader("https://example.invalid/main.yml", strings.NewReader(string(input))) }},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
result, err := Load[importTestConfig](test.source(), Options[importTestConfig]{Imports: true, Filesystem: context}) result, err := testLoad[importTestConfig](test.source(), Options[importTestConfig]{Imports: true, Filesystem: context})
if err != nil || result.Config.Settings.First != "imported" { if err != nil || result.Config.Settings.First != "imported" {
t.Fatalf("explicit filesystem result = %#v, %v", result, err) t.Fatalf("explicit filesystem result = %#v, %v", result, err)
} }
@@ -318,7 +318,7 @@ func TestFileImportRootCanContainTheSelectedFileDirectory(t *testing.T) {
root := t.TempDir() root := t.TempDir()
mainPath := writeImportFile(t, root, "configs/main.yml", "configkit: {imports: [{path: ../shared.yml}]}\n") mainPath := writeImportFile(t, root, "configs/main.yml", "configkit: {imports: [{path: ../shared.yml}]}\n")
writeImportFile(t, root, "shared.yml", "settings: {root: shared}\n") writeImportFile(t, root, "shared.yml", "settings: {root: shared}\n")
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{ result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{
Imports: true, Filesystem: &FilesystemContext{Root: root}, Imports: true, Filesystem: &FilesystemContext{Root: root},
}) })
if err != nil || result.Config.Settings.Root != "shared" { if err != nil || result.Config.Settings.Root != "shared" {
@@ -455,7 +455,7 @@ func requireLoadCode(t *testing.T, source Source, options Options[importTestConf
func requireAnyLoadError(t *testing.T, source Source, options Options[importTestConfig]) *LoadError { func requireAnyLoadError(t *testing.T, source Source, options Options[importTestConfig]) *LoadError {
t.Helper() t.Helper()
result, err := Load[importTestConfig](source, options) result, err := testLoad[importTestConfig](source, options)
if result != nil || err == nil { if result != nil || err == nil {
t.Fatalf("result, error = %#v, %v", result, err) t.Fatalf("result, error = %#v, %v", result, err)
} }

View File

@@ -46,14 +46,14 @@ func TestParallelLoadsShareRulesButNotMutableValues(t *testing.T) {
go func(index int) { go func(index int) {
defer group.Done() defer group.Done()
input := fmt.Sprintf("custom: value-%d\n", index) input := fmt.Sprintf("custom: value-%d\n", index)
results[index], errorsByIndex[index] = Load(Bytes("parallel.yml", []byte(input)), Options[independentConfig]{Definition: definition}) results[index], errorsByIndex[index] = testLoad(Bytes("parallel.yml", []byte(input)), Options[independentConfig]{Definition: definition})
}(index) }(index)
} }
group.Wait() group.Wait()
for index, err := range errorsByIndex { for index, err := range errorsByIndex {
if err != nil { if err != nil {
t.Fatalf("Load(%d) failed: %v", index, err) t.Fatalf("testLoad(%d) failed: %v", index, err)
} }
} }
results[0].Config.Labels["default"] = "mutated" results[0].Config.Labels["default"] = "mutated"
@@ -88,7 +88,7 @@ func TestConcurrentLoadsOfCopiedReaderSourceHaveOneClaim(t *testing.T) {
go func(candidate Source) { go func(candidate Source) {
defer group.Done() defer group.Done()
<-start <-start
_, err := Load[config](candidate) _, err := testLoad[config](candidate)
errorsFound <- err errorsFound <- err
}(candidate) }(candidate)
} }

View File

@@ -110,11 +110,11 @@ func (r *Result[T]) Diagnostics() []Diagnostic {
// Load parses and strictly decodes zero or one selected source, explicitly enabled // Load parses and strictly decodes zero or one selected source, explicitly enabled
// imports, and one selected profile. It accepts at most one Options value and // imports, and one selected profile. It accepts at most one Options value and
// returns no result when any error is reported. // returns no result when any error is reported.
func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) { func Load[T any](source Source, secrets SecretBinding, supplied ...Options[T]) (*Result[T], error) {
return loadWithSecretLookup(source, os.LookupEnv, supplied...) return loadWithSecretLookup(source, secrets, os.LookupEnv, supplied...)
} }
func loadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ...Options[T]) (*Result[T], error) { func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup secretLookup, supplied ...Options[T]) (*Result[T], error) {
accumulator := diagnosticAccumulator{} accumulator := diagnosticAccumulator{}
options, diagnostics := selectOptions(source, supplied) options, diagnostics := selectOptions(source, supplied)
accumulator.add(diagnostics...) accumulator.add(diagnostics...)
@@ -128,7 +128,7 @@ func loadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ..
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return nil, accumulator.loadError() return nil, accumulator.loadError()
} }
schema, schemaIssue := buildSchema(reflect.TypeFor[T]()) schema, schemaIssue := buildSchema(reflect.TypeFor[T](), secrets)
if schemaIssue != nil { if schemaIssue != nil {
accumulator.add(diagnosticFromSchemaIssue(schemaIssue)) accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
return nil, accumulator.loadError() return nil, accumulator.loadError()

View File

@@ -38,7 +38,7 @@ func TestLoadSingleSourceKindsAndDefaultsOnly(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
var defaultsCalls atomic.Int32 var defaultsCalls atomic.Int32
result, err := Load(test.source(), Options[config]{Definition: Definition[config]{ result, err := testLoad(test.source(), Options[config]{Definition: Definition[config]{
Defaults: func() config { Defaults: func() config {
defaultsCalls.Add(1) defaultsCalls.Add(1)
return config{Name: "default"} return config{Name: "default"}
@@ -92,7 +92,7 @@ func TestLoadVersionStatesWarningsAndSafeFailures(t *testing.T) {
validation.Warning(versionPath, "review schema compatibility", Keys("missing_related")) validation.Warning(versionPath, "review schema compatibility", Keys("missing_related"))
return nil return nil
}} }}
result, err := Load(test.source, Options[config]{Definition: Definition[config]{Version: rule}}) result, err := testLoad(test.source, Options[config]{Definition: Definition[config]{Version: rule}})
if test.wantErr != "" { if test.wantErr != "" {
if result != nil || err == nil { if result != nil || err == nil {
t.Fatalf("result, err = %#v, %v", result, err) t.Fatalf("result, err = %#v, %v", result, err)
@@ -144,21 +144,21 @@ func TestLoadDecodesIntegerVersionOnlyAtConfiguredStringPath(t *testing.T) {
load func(Source, *VersionRule) (string, error) load func(Source, *VersionRule) (string, error)
}{ }{
{name: "integer into string", source: "version: 4\n", load: func(source Source, rule *VersionRule) (string, error) { {name: "integer into string", source: "version: 4\n", load: func(source Source, rule *VersionRule) (string, error) {
result, err := Load(source, Options[stringConfig]{Definition: Definition[stringConfig]{Version: rule}}) result, err := testLoad(source, Options[stringConfig]{Definition: Definition[stringConfig]{Version: rule}})
if err != nil { if err != nil {
return "", err return "", err
} }
return result.Config.Version, nil return result.Config.Version, nil
}}, }},
{name: "string into string", source: "version: \"4\"\n", load: func(source Source, rule *VersionRule) (string, error) { {name: "string into string", source: "version: \"4\"\n", load: func(source Source, rule *VersionRule) (string, error) {
result, err := Load(source, Options[stringConfig]{Definition: Definition[stringConfig]{Version: rule}}) result, err := testLoad(source, Options[stringConfig]{Definition: Definition[stringConfig]{Version: rule}})
if err != nil { if err != nil {
return "", err return "", err
} }
return result.Config.Version, nil return result.Config.Version, nil
}}, }},
{name: "integer into pointer", source: "version: 4\n", load: func(source Source, rule *VersionRule) (string, error) { {name: "integer into pointer", source: "version: 4\n", load: func(source Source, rule *VersionRule) (string, error) {
result, err := Load(source, Options[pointerConfig]{Definition: Definition[pointerConfig]{Version: rule}}) result, err := testLoad(source, Options[pointerConfig]{Definition: Definition[pointerConfig]{Version: rule}})
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -168,7 +168,7 @@ func TestLoadDecodesIntegerVersionOnlyAtConfiguredStringPath(t *testing.T) {
return *result.Config.Version, nil return *result.Config.Version, nil
}}, }},
{name: "string into pointer", source: "version: \"4\"\n", load: func(source Source, rule *VersionRule) (string, error) { {name: "string into pointer", source: "version: \"4\"\n", load: func(source Source, rule *VersionRule) (string, error) {
result, err := Load(source, Options[pointerConfig]{Definition: Definition[pointerConfig]{Version: rule}}) result, err := testLoad(source, Options[pointerConfig]{Definition: Definition[pointerConfig]{Version: rule}})
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -194,7 +194,7 @@ func TestLoadDecodesIntegerVersionOnlyAtConfiguredStringPath(t *testing.T) {
}) })
} }
if result, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[stringConfig]{}); result != nil { if result, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[stringConfig]{}); result != nil {
t.Fatalf("load without rule = %#v, %v", result, err) t.Fatalf("load without rule = %#v, %v", result, err)
} else { } else {
var loadError *LoadError var loadError *LoadError
@@ -207,7 +207,7 @@ func TestLoadDecodesIntegerVersionOnlyAtConfiguredStringPath(t *testing.T) {
Label string `yaml:"label"` Label string `yaml:"label"`
} }
rule := &VersionRule{Path: versionPath, Check: func(Version, *Validation) error { return nil }} rule := &VersionRule{Path: versionPath, Check: func(Version, *Validation) error { return nil }}
if result, err := Load(Bytes("version.yml", []byte("version: 4\nlabel: 5\n")), Options[unrelatedConfig]{Definition: Definition[unrelatedConfig]{Version: rule}}); result != nil { if result, err := testLoad(Bytes("version.yml", []byte("version: 4\nlabel: 5\n")), Options[unrelatedConfig]{Definition: Definition[unrelatedConfig]{Version: rule}}); result != nil {
t.Fatalf("unrelated coercion = %#v, %v", result, err) t.Fatalf("unrelated coercion = %#v, %v", result, err)
} else { } else {
var loadError *LoadError var loadError *LoadError
@@ -240,7 +240,7 @@ func TestLoadVersionScalarAndPathBoundaries(t *testing.T) {
checked = version checked = version
return nil return nil
}} }}
result, err := Load(Bytes("version.yml", []byte("version: "+test.value+"\n")), Options[config]{Definition: Definition[config]{Version: rule}}) result, err := testLoad(Bytes("version.yml", []byte("version: "+test.value+"\n")), Options[config]{Definition: Definition[config]{Version: rule}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -266,7 +266,7 @@ func TestLoadVersionScalarAndPathBoundaries(t *testing.T) {
called = true called = true
return nil return nil
}} }}
result, err := Load(Bytes("version.yml", []byte("version: "+test.value+"\n")), Options[config]{Definition: Definition[config]{Version: rule}}) result, err := testLoad(Bytes("version.yml", []byte("version: "+test.value+"\n")), Options[config]{Definition: Definition[config]{Version: rule}})
if result != nil || called { if result != nil || called {
t.Fatalf("result = %#v, checker called = %v", result, called) t.Fatalf("result = %#v, checker called = %v", result, called)
} }
@@ -289,7 +289,7 @@ func TestLoadVersionScalarAndPathBoundaries(t *testing.T) {
} }
return nil return nil
}} }}
result, err := Load(Bytes("nested.yml", []byte("versions:\n \"0\": 4\nsibling: quoted\n")), Options[nested]{Definition: Definition[nested]{Version: rule}}) result, err := testLoad(Bytes("nested.yml", []byte("versions:\n \"0\": 4\nsibling: quoted\n")), Options[nested]{Definition: Definition[nested]{Version: rule}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -299,7 +299,7 @@ func TestLoadVersionScalarAndPathBoundaries(t *testing.T) {
type sequenceConfig struct { type sequenceConfig struct {
Versions []namedVersion `yaml:"versions"` Versions []namedVersion `yaml:"versions"`
} }
sequenceResult, err := Load(Bytes("sequence.yml", []byte("versions: [6]\n")), Options[sequenceConfig]{Definition: Definition[sequenceConfig]{Version: &VersionRule{Path: parsed, Check: func(Version, *Validation) error { return nil }}}}) sequenceResult, err := testLoad(Bytes("sequence.yml", []byte("versions: [6]\n")), Options[sequenceConfig]{Definition: Definition[sequenceConfig]{Version: &VersionRule{Path: parsed, Check: func(Version, *Validation) error { return nil }}}})
if err != nil || len(sequenceResult.Config.Versions) != 1 || sequenceResult.Config.Versions[0] != "6" { if err != nil || len(sequenceResult.Config.Versions) != 1 || sequenceResult.Config.Versions[0] != "6" {
t.Fatalf("sequence configuration = %#v, %v", sequenceResult, err) t.Fatalf("sequence configuration = %#v, %v", sequenceResult, err)
} }
@@ -311,13 +311,13 @@ func TestLoadVersionScalarAndPathBoundaries(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
escapedResult, err := Load(Bytes("escaped.yml", []byte("versions:\n a/b: 9\n")), Options[escaped]{Definition: Definition[escaped]{Version: &VersionRule{Path: escapedPath, Check: func(Version, *Validation) error { return nil }}}}) escapedResult, err := testLoad(Bytes("escaped.yml", []byte("versions:\n a/b: 9\n")), Options[escaped]{Definition: Definition[escaped]{Version: &VersionRule{Path: escapedPath, Check: func(Version, *Validation) error { return nil }}}})
if err != nil || escapedResult.Config.Versions["a/b"] != "9" { if err != nil || escapedResult.Config.Versions["a/b"] != "9" {
t.Fatalf("escaped configuration = %#v, %v", escapedResult, err) t.Fatalf("escaped configuration = %#v, %v", escapedResult, err)
} }
siblingRule := &VersionRule{Path: Keys("versions", "target"), Check: func(Version, *Validation) error { return nil }} siblingRule := &VersionRule{Path: Keys("versions", "target"), Check: func(Version, *Validation) error { return nil }}
if result, err := Load(Bytes("sibling.yml", []byte("versions:\n target: 4\n other: 5\n")), Options[escaped]{Definition: Definition[escaped]{Version: siblingRule}}); result != nil { if result, err := testLoad(Bytes("sibling.yml", []byte("versions:\n target: 4\n other: 5\n")), Options[escaped]{Definition: Definition[escaped]{Version: siblingRule}}); result != nil {
t.Fatalf("same-type sibling result = %#v, %v", result, err) t.Fatalf("same-type sibling result = %#v, %v", result, err)
} else { } else {
requireDiagnostic(t, err, CodeDecodeType, "/versions/other") requireDiagnostic(t, err, CodeDecodeType, "/versions/other")
@@ -332,16 +332,16 @@ func TestLoadVersionDestinationTypesKeepOrdinaryDecoding(t *testing.T) {
type config struct { type config struct {
Version int8 `yaml:"version"` Version int8 `yaml:"version"`
} }
result, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}}) result, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
if err != nil || result.Config.Version != 4 { if err != nil || result.Config.Version != 4 {
t.Fatalf("result = %#v, %v", result, err) t.Fatalf("result = %#v, %v", result, err)
} }
result, err = Load(Bytes("version.yml", []byte("version: 999\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}}) result, err = testLoad(Bytes("version.yml", []byte("version: 999\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
if result != nil { if result != nil {
t.Fatalf("range result = %#v", result) t.Fatalf("range result = %#v", result)
} }
requireDiagnostic(t, err, CodeDecodeRange, "/version") requireDiagnostic(t, err, CodeDecodeRange, "/version")
result, err = Load(Bytes("version.yml", []byte("version: \"4\"\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}}) result, err = testLoad(Bytes("version.yml", []byte("version: \"4\"\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
if result != nil { if result != nil {
t.Fatalf("quoted integer result = %#v", result) t.Fatalf("quoted integer result = %#v", result)
} }
@@ -352,11 +352,11 @@ func TestLoadVersionDestinationTypesKeepOrdinaryDecoding(t *testing.T) {
type config struct { type config struct {
Version any `yaml:"version"` Version any `yaml:"version"`
} }
result, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}}) result, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
if err != nil || result.Config.Version != int64(4) { if err != nil || result.Config.Version != int64(4) {
t.Fatalf("result = %#v, %v", result, err) t.Fatalf("result = %#v, %v", result, err)
} }
result, err = Load(Bytes("version.yml", []byte("version: 123456789012345678901234567890\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}}) result, err = testLoad(Bytes("version.yml", []byte("version: 123456789012345678901234567890\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
if result != nil { if result != nil {
t.Fatalf("range result = %#v", result) t.Fatalf("range result = %#v", result)
} }
@@ -371,21 +371,21 @@ func TestLoadVersionDestinationTypesKeepOrdinaryDecoding(t *testing.T) {
type config struct { type config struct {
Version validatedText `yaml:"version"` Version validatedText `yaml:"version"`
} }
_, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}}) _, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
return err return err
}}, }},
{name: "duration", load: func() error { {name: "duration", load: func() error {
type config struct { type config struct {
Version time.Duration `yaml:"version"` Version time.Duration `yaml:"version"`
} }
_, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}}) _, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
return err return err
}}, }},
{name: "secret", load: func() error { {name: "secret", load: func() error {
type config struct { type config struct {
Version Secret `yaml:"version"` Version Secret `yaml:"version"`
} }
_, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}}) _, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
return err return err
}}, }},
} { } {
@@ -409,7 +409,7 @@ func TestLoadVersionPolicyStopsLaterHooks(t *testing.T) {
validation.Error(Keys("version"), "unsupported configuration version") validation.Error(Keys("version"), "unsupported configuration version")
return nil return nil
}} }}
result, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{
Defaults: func() config { defaultsCalled = true; return config{} }, Defaults: func() config { defaultsCalled = true; return config{} },
Version: rule, Version: rule,
Resolve: func(*config, *Resolution) error { resolutionCalled = true; return nil }, Resolve: func(*config, *Resolution) error { resolutionCalled = true; return nil },
@@ -440,7 +440,7 @@ func TestLoadVersionReportSuppressesReturnedErrorTextAndDefaults(t *testing.T) {
Name string `yaml:"name"` Name string `yaml:"name"`
} }
var defaultsCalls atomic.Int32 var defaultsCalls atomic.Int32
result, err := Load(Bytes("version.yml", []byte("name: value\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad(Bytes("version.yml", []byte("name: value\n")), Options[config]{Definition: Definition[config]{
Defaults: func() config { Defaults: func() config {
defaultsCalls.Add(1) defaultsCalls.Add(1)
return config{Name: "default"} return config{Name: "default"}
@@ -474,7 +474,7 @@ func TestLoadMetadataReflectsDefaultsInputAndDetachedRecords(t *testing.T) {
Nested nested `yaml:"nested"` Nested nested `yaml:"nested"`
Nullable map[string]string `yaml:"nullable"` Nullable map[string]string `yaml:"nullable"`
} }
result, err := Load(Bytes("metadata.yml", []byte("nested: {set: configured}\nnullable: null\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad(Bytes("metadata.yml", []byte("nested: {set: configured}\nnullable: null\n")), Options[config]{Definition: Definition[config]{
Defaults: func() config { Defaults: func() config {
return config{Nested: nested{Kept: "default", Set: "default"}, Nullable: map[string]string{"key": "value"}} return config{Nested: nested{Kept: "default", Set: "default"}, Nullable: map[string]string{"key": "value"}}
}, },
@@ -538,7 +538,7 @@ func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) {
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
result, err := Load(test.source, test.options...) result, err := testLoad(test.source, test.options...)
if result != nil || err == nil { if result != nil || err == nil {
t.Fatalf("result, err = %#v, %v", result, err) t.Fatalf("result, err = %#v, %v", result, err)
} }
@@ -549,7 +549,7 @@ func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) {
}) })
} }
result, err := Load[config](Bytes("directive.yml", []byte("nested:\n configkit: {}\n"))) result, err := testLoad[config](Bytes("directive.yml", []byte("nested:\n configkit: {}\n")))
if result != nil || err == nil { if result != nil || err == nil {
t.Fatalf("result, err = %#v, %v", result, err) t.Fatalf("result, err = %#v, %v", result, err)
} }
@@ -577,9 +577,9 @@ func TestLoadReturnsNilForSourceSchemaAndDecodeFailures(t *testing.T) {
run func() (any, error) run func() (any, error)
code Code code Code
}{ }{
{name: "source", run: func() (any, error) { return Load[validConfig](File(filepath.Join(t.TempDir(), "missing.yml"))) }, code: CodeSourceMissing}, {name: "source", run: func() (any, error) { return testLoad[validConfig](File(filepath.Join(t.TempDir(), "missing.yml"))) }, code: CodeSourceMissing},
{name: "schema", run: func() (any, error) { return Load[invalidSchema](Bytes("schema.yml", []byte("{}\n"))) }, code: CodeSchemaUnsupportedType}, {name: "schema", run: func() (any, error) { return testLoad[invalidSchema](Bytes("schema.yml", []byte("{}\n"))) }, code: CodeSchemaUnsupportedType},
{name: "decode", run: func() (any, error) { return Load[validConfig](Bytes("decode.yml", []byte("unknown: 1\n"))) }, code: CodeDecodeUnknownField}, {name: "decode", run: func() (any, error) { return testLoad[validConfig](Bytes("decode.yml", []byte("unknown: 1\n"))) }, code: CodeDecodeUnknownField},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {

View File

@@ -8,7 +8,7 @@ import (
func TestMetadataHistoricalPaths(t *testing.T) { func TestMetadataHistoricalPaths(t *testing.T) {
t.Parallel() t.Parallel()
result, err := Load[map[string]any](Bytes("base", []byte("retired: {child: 1}\nshape: {'0': base}\n'a/b~': {'': 2}\n")), Options[map[string]any]{ result, err := testLoad[map[string]any](Bytes("base", []byte("retired: {child: 1}\nshape: {'0': base}\n'a/b~': {'': 2}\n")), Options[map[string]any]{
Overrides: []Override{ Overrides: []Override{
{Path: Keys("retired"), Value: nil, Flag: "--retired"}, {Path: Keys("retired"), Value: nil, Flag: "--retired"},
{Path: Keys("shape"), Value: []any{"replacement"}, Flag: "--shape"}, {Path: Keys("shape"), Value: []any{"replacement"}, Flag: "--shape"},
@@ -61,7 +61,7 @@ func TestHistoricalPathsExposeHiddenBaseOwnershipWithoutResolvingBaseSecrets(t *
writeImportFile(t, dir, "first.yml", "definitions: {recap: {prompt: recap}}\n") writeImportFile(t, dir, "first.yml", "definitions: {recap: {prompt: recap}}\n")
writeImportFile(t, dir, "second.yml", "definitions: {recap: {output: recap.md}}\n") writeImportFile(t, dir, "second.yml", "definitions: {recap: {output: recap.md}}\n")
writeImportFile(t, dir, "profiles/testing.yml", "definitions: null\ncredential: "+credential+"\n") writeImportFile(t, dir, "profiles/testing.yml", "definitions: null\ncredential: "+credential+"\n")
result, err := Load[config](File(root), Options[config]{Imports: true, Profile: "testing"}) result, err := testLoad[config](File(root), Options[config]{Imports: true, Profile: "testing"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -232,7 +232,7 @@ func TestResolutionInputKeepsDetachedEntryMetadata(t *testing.T) {
} }
func TestQueriesRespectActualHistoricalAndEffectiveContainers(t *testing.T) { func TestQueriesRespectActualHistoricalAndEffectiveContainers(t *testing.T) {
result, err := Load[map[string]any](Bytes("base", []byte("items: [1]\nm: {'0': 2}\nx: 3\nretired: {child: 4}\n")), Options[map[string]any]{Overrides: []Override{{Path: Keys("retired"), Value: "removed", Flag: "--retired"}}}) result, err := testLoad[map[string]any](Bytes("base", []byte("items: [1]\nm: {'0': 2}\nx: 3\nretired: {child: 4}\n")), Options[map[string]any]{Overrides: []Override{{Path: Keys("retired"), Value: "removed", Flag: "--retired"}}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -56,7 +56,7 @@ func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *te
{Path: Keys("settings", "labels"), Value: map[string]any{"cli": "selected"}, Flag: "--label"}, {Path: Keys("settings", "labels"), Value: map[string]any{"cli": "selected"}, Flag: "--label"},
{Path: Keys("settings", "api_key"), Value: "CLI_SECRET", Flag: "--api-key-env"}, {Path: Keys("settings", "api_key"), Value: "CLI_SECRET", Flag: "--api-key-env"},
} }
result, err := loadWithSecretLookup(File(mainPath), func(name string) (string, bool) { result, err := testLoadWithSecretLookup(File(mainPath), func(name string) (string, bool) {
if name != "CLI_SECRET" { if name != "CLI_SECRET" {
t.Fatalf("looked up superseded secret name %q", name) t.Fatalf("looked up superseded secret name %q", name)
} }
@@ -83,7 +83,7 @@ func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *te
}, },
}) })
if err != nil { if err != nil {
t.Fatalf("loadWithSecretLookup() error = %v", err) t.Fatalf("testLoadWithSecretLookup() error = %v", err)
} }
want := overrideLifecycleSettings{ want := overrideLifecycleSettings{
Target: "cli", Labels: map[string]string{"base": "retained", "profile": "retained", "cli": "selected"}, Target: "cli", Labels: map[string]string{"base": "retained", "profile": "retained", "cli": "selected"},
@@ -123,7 +123,7 @@ func TestOverridesSupportDefaultsOnlyMapMergeListReplacementAndNumericKeys(t *te
"int": int8(1), "uint": uint16(2), "float": float32(3.5), "bool": false, "int": int8(1), "uint": uint16(2), "float": float32(3.5), "bool": false,
"null": nil, "list": []any{"value"}, "map": map[string]any{"key": "value"}, "null": nil, "list": []any{"value"}, "map": map[string]any{"key": "value"},
} }
result, err := Load(NoSource(), Options[config]{ result, err := testLoad(NoSource(), Options[config]{
Definition: Definition[config]{Defaults: func() config { Definition: Definition[config]{Defaults: func() config {
return config{Labels: map[string]string{"kept": "default"}, Items: []int{9}} return config{Labels: map[string]string{"kept": "default"}, Items: []int{9}}
}}, }},
@@ -194,7 +194,7 @@ func TestOverrideValidationRejectsPathsOverlapValuesAndProtectedTargets(t *testi
{name: "label", overrides: []Override{{Path: Keys("scalar"), Value: "x", Flag: "\nCANARY"}}, code: CodeOverrideInvalidLabel}, {name: "label", overrides: []Override{{Path: Keys("scalar"), Value: "x", Flag: "\nCANARY"}}, code: CodeOverrideInvalidLabel},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
result, err := Load(base, Options[config]{Definition: Definition[config]{Version: version}, Overrides: test.overrides}) result, err := testLoad(base, Options[config]{Definition: Definition[config]{Version: version}, Overrides: test.overrides})
if result != nil { if result != nil {
t.Fatalf("result = %#v, want nil", result) t.Fatalf("result = %#v, want nil", result)
} }
@@ -213,7 +213,7 @@ func TestOverrideCannotReplaceSchemaVersionAncestor(t *testing.T) {
type config struct { type config struct {
Schema schemaConfig `yaml:"schema"` Schema schemaConfig `yaml:"schema"`
} }
_, err := Load(Bytes("version.yml", []byte("schema: {version: 4}\n")), Options[config]{ _, err := testLoad(Bytes("version.yml", []byte("schema: {version: 4}\n")), Options[config]{
Definition: Definition[config]{Version: &VersionRule{Path: Keys("schema", "version"), Check: func(Version, *Validation) error { return nil }}}, Definition: Definition[config]{Version: &VersionRule{Path: Keys("schema", "version"), Check: func(Version, *Validation) error { return nil }}},
Overrides: []Override{{Path: Keys("schema"), Value: map[string]any{"other": "value"}, Flag: "--schema"}}, Overrides: []Override{{Path: Keys("schema"), Value: map[string]any{"other": "value"}, Flag: "--schema"}},
}) })
@@ -224,7 +224,7 @@ func TestOverrideDecodeFailureUsesFlagSourceWithoutRawValue(t *testing.T) {
type config struct { type config struct {
Count int `yaml:"count"` Count int `yaml:"count"`
} }
result, err := Load(Bytes("base.yml", []byte("count: 1\n")), Options[config]{Overrides: []Override{{ result, err := testLoad(Bytes("base.yml", []byte("count: 1\n")), Options[config]{Overrides: []Override{{
Path: Keys("missing"), Value: "RAW-ARG-CANARY", Flag: "--missing", Path: Keys("missing"), Value: "RAW-ARG-CANARY", Flag: "--missing",
}}}) }}})
if result != nil { if result != nil {
@@ -249,7 +249,7 @@ func TestOverrideTypeMismatchUsesNormalStrictDecode(t *testing.T) {
type config struct { type config struct {
Count int `yaml:"count"` Count int `yaml:"count"`
} }
_, err := Load(Bytes("base.yml", []byte("count: 1\n")), Options[config]{Overrides: []Override{{ _, err := testLoad(Bytes("base.yml", []byte("count: 1\n")), Options[config]{Overrides: []Override{{
Path: Keys("count"), Value: "not-an-integer", Flag: "--count", Path: Keys("count"), Value: "not-an-integer", Flag: "--count",
}}}) }}})
loadError := requireSecretLoadCode(t, err, CodeDecodeType) loadError := requireSecretLoadCode(t, err, CodeDecodeType)
@@ -262,7 +262,7 @@ func TestOverrideEqualReplacementStillAppearsInHistory(t *testing.T) {
type config struct { type config struct {
Value string `yaml:"value"` Value string `yaml:"value"`
} }
result, err := Load(Bytes("equal.yml", []byte("value: same\n")), Options[config]{Overrides: []Override{{Path: Keys("value"), Value: "same", Flag: "--value"}}}) result, err := testLoad(Bytes("equal.yml", []byte("value: same\n")), Options[config]{Overrides: []Override{{Path: Keys("value"), Value: "same", Flag: "--value"}}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -40,7 +40,7 @@ nullable: null
value: scalar value: scalar
`) `)
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{ result, err := testLoad[profileTestConfig](File(mainPath), Options[profileTestConfig]{
Imports: true, Imports: true,
Profile: "testing", Profile: "testing",
Definition: Definition[profileTestConfig]{Defaults: func() profileTestConfig { Definition: Definition[profileTestConfig]{Defaults: func() profileTestConfig {
@@ -101,12 +101,12 @@ func TestLoadProfileSelectionUsesOneFixedYAMLFile(t *testing.T) {
writeImportFile(t, directory, "profiles/default.yml", "settings: {changed: forbidden}\n") writeImportFile(t, directory, "profiles/default.yml", "settings: {changed: forbidden}\n")
for _, name := range []string{"", "default"} { for _, name := range []string{"", "default"} {
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: name}) result, err := testLoad[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: name})
if err != nil || result.Config.Settings.Changed != "" || len(result.Sources()) != 1 { if err != nil || result.Config.Settings.Changed != "" || len(result.Sources()) != 1 {
t.Fatalf("inactive profile %q = %#v, %v", name, result, err) t.Fatalf("inactive profile %q = %#v, %v", name, result, err)
} }
} }
selected, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing"}) selected, err := testLoad[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing"})
if err != nil || selected.Config.Settings.Changed != "selected" || len(selected.Sources()) != 2 { if err != nil || selected.Config.Settings.Changed != "selected" || len(selected.Sources()) != 2 {
t.Fatalf("selected profile = %#v, %v", selected, err) t.Fatalf("selected profile = %#v, %v", selected, err)
} }
@@ -150,7 +150,7 @@ func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
directory := t.TempDir() directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: 4}\n") mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: 4}\n")
writeImportFile(t, directory, "profiles/testing.yml", "schema: {other: allowed}\n") writeImportFile(t, directory, "profiles/testing.yml", "schema: {other: allowed}\n")
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}}) result, err := testLoad[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}})
if err != nil || result.Config.Schema["other"] != "allowed" { if err != nil || result.Config.Schema["other"] != "allowed" {
t.Fatalf("mapping sibling = %#v, %v", result, err) t.Fatalf("mapping sibling = %#v, %v", result, err)
} }
@@ -201,7 +201,7 @@ func TestLoadProfileKeepsBaseFailuresAndFilesystemContainment(t *testing.T) {
t.Run("byte source context", func(t *testing.T) { t.Run("byte source context", func(t *testing.T) {
directory := t.TempDir() directory := t.TempDir()
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: selected}\n") writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: selected}\n")
result, err := Load[profileTestConfig](Bytes("s3://bucket/main.yml", []byte("{}\n")), Options[profileTestConfig]{ result, err := testLoad[profileTestConfig](Bytes("s3://bucket/main.yml", []byte("{}\n")), Options[profileTestConfig]{
Profile: "testing", Filesystem: &FilesystemContext{BaseDir: directory, Root: directory}, Profile: "testing", Filesystem: &FilesystemContext{BaseDir: directory, Root: directory},
}) })
if err != nil || result.Config.Settings.Changed != "selected" { if err != nil || result.Config.Settings.Changed != "selected" {
@@ -251,7 +251,7 @@ func TestLoadProfileCountsAgainstAggregateBudgets(t *testing.T) {
func requireProfileLoadCode(t *testing.T, source Source, options Options[profileTestConfig], code Code) *LoadError { func requireProfileLoadCode(t *testing.T, source Source, options Options[profileTestConfig], code Code) *LoadError {
t.Helper() t.Helper()
result, err := Load[profileTestConfig](source, options) result, err := testLoad[profileTestConfig](source, options)
if result != nil || err == nil { if result != nil || err == nil {
t.Fatalf("result, error = %#v, %v", result, err) t.Fatalf("result, error = %#v, %v", result, err)
} }

View File

@@ -199,7 +199,7 @@ func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor
case descriptorSecret: case descriptorSecret:
node, issue := o.node(document.String, path, depth) node, issue := o.node(document.String, path, depth)
if issue == nil { if issue == nil {
node.Text = value.Interface().(Secret).EnvName() node.Text = o.schema.secrets.Read(value).Name
} }
return node, issue return node, issue
case descriptorText: case descriptorText:

View File

@@ -359,9 +359,9 @@ func TestCompareDocumentTreesTracksSemanticChanges(t *testing.T) {
func mustSchemaFor[T any](t *testing.T) *schemaDescriptor { func mustSchemaFor[T any](t *testing.T) *schemaDescriptor {
t.Helper() t.Helper()
schema, issue := buildSchema(reflect.TypeFor[T]()) schema, issue := testBuildSchema(reflect.TypeFor[T]())
if issue != nil { if issue != nil {
t.Fatalf("buildSchema() issue = %#v", issue) t.Fatalf("testBuildSchema() issue = %#v", issue)
} }
return schema return schema
} }

View File

@@ -70,7 +70,7 @@ empty: {}
}, },
} }
result, err := Load[layeredConfig](File(mainPath), Options[layeredConfig]{ result, err := testLoad[layeredConfig](File(mainPath), Options[layeredConfig]{
Imports: true, Profile: "testing", Definition: definition, Imports: true, Profile: "testing", Definition: definition,
}) })
if err != nil { if err != nil {
@@ -134,7 +134,7 @@ empty: {}
t.Fatalf("canonical resolution history = %#v", lastValue) t.Fatalf("canonical resolution history = %#v", lastValue)
} }
schema, issue := buildSchema(reflect.TypeFor[layeredConfig]()) schema, issue := testBuildSchema(reflect.TypeFor[layeredConfig]())
if issue != nil { if issue != nil {
t.Fatal(issue) t.Fatal(issue)
} }
@@ -173,7 +173,7 @@ func TestSelectedProfileLoadsRemainIndependentConcurrently(t *testing.T) {
group.Add(1) group.Add(1)
go func() { go func() {
defer group.Done() defer group.Done()
result, err := Load[layeredConfig](File(mainPath), options) result, err := testLoad[layeredConfig](File(mainPath), options)
if err != nil { if err != nil {
errors <- err errors <- err
return return
@@ -236,7 +236,7 @@ composite: {first: first}
CLIEqual string `yaml:"cli_equal"` CLIEqual string `yaml:"cli_equal"`
} }
var callbackErr error var callbackErr error
result, err := Load[config](File(mainPath), Options[config]{ result, err := testLoad[config](File(mainPath), Options[config]{
Imports: true, Profile: "testing", Overrides: []Override{{Path: Keys("cli_equal"), Value: "same", Flag: "--cli-equal"}}, Imports: true, Profile: "testing", Overrides: []Override{{Path: Keys("cli_equal"), Value: "same", Flag: "--cli-equal"}},
Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error { Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
check := func(ok bool, message string) { check := func(ok bool, message string) {
@@ -322,7 +322,7 @@ func TestResolutionInputUsesEntryShapesAcrossTypedAndDynamicValues(t *testing.T)
Dynamic any `yaml:"dynamic"` Dynamic any `yaml:"dynamic"`
} }
var callbackErr error var callbackErr error
_, err := Load[config](Bytes("shapes.yml", []byte("name: inlined\nmappings: {'-0': key, 'a/b~': escaped}\narray: [zero, one]\ndynamic: {nested: [dynamic]}\n")), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error { _, err := testLoad[config](Bytes("shapes.yml", []byte("name: inlined\nmappings: {'-0': key, 'a/b~': escaped}\narray: [zero, one]\ndynamic: {nested: [dynamic]}\n")), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
check := func(path Path, want bool) { check := func(path Path, want bool) {
_, found := resolution.InputInfo(path) _, found := resolution.InputInfo(path)
if found != want && callbackErr == nil { if found != want && callbackErr == nil {
@@ -371,7 +371,7 @@ func TestResolutionInputDistinguishesNonFileRootsAndImportedFiles(t *testing.T)
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
var callbackErr error var callbackErr error
_, err := Load[config](test.source(), Options[config]{Imports: true, Filesystem: context, Definition: Definition[config]{ _, err := testLoad[config](test.source(), Options[config]{Imports: true, Filesystem: context, Definition: Definition[config]{
Defaults: func() config { return config{Default: "retained"} }, Defaults: func() config { return config{Default: "retained"} },
Resolve: func(_ *config, resolution *Resolution) error { Resolve: func(_ *config, resolution *Resolution) error {
root, _ := resolution.InputInfo(Keys("root")) root, _ := resolution.InputInfo(Keys("root"))
@@ -398,7 +398,7 @@ func TestResolutionInputDistinguishesNonFileRootsAndImportedFiles(t *testing.T)
} }
var callbackErr error var callbackErr error
_, err := Load[config](Bytes("memory://standalone.yml", []byte("root: memory\n")), Options[config]{Definition: Definition[config]{ _, err := testLoad[config](Bytes("memory://standalone.yml", []byte("root: memory\n")), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Default: "retained"} }, Defaults: func() config { return config{Default: "retained"} },
Resolve: func(_ *config, resolution *Resolution) error { Resolve: func(_ *config, resolution *Resolution) error {
root, rootFound := resolution.InputInfo(Keys("root")) root, rootFound := resolution.InputInfo(Keys("root"))
@@ -426,7 +426,7 @@ func TestResolutionInputRemainsFixedAcrossRecordedMutations(t *testing.T) {
Values map[string]string `yaml:"values"` Values map[string]string `yaml:"values"`
} }
var callbackErr error var callbackErr error
result, err := Load[config](Bytes("mutations.yml", []byte("existing: original\nvalues: {original: move}\n")), Options[config]{Definition: Definition[config]{Resolve: func(value *config, resolution *Resolution) error { result, err := testLoad[config](Bytes("mutations.yml", []byte("existing: original\nvalues: {original: move}\n")), Options[config]{Definition: Definition[config]{Resolve: func(value *config, resolution *Resolution) error {
original, originalFound := resolution.InputInfo(Keys("values", "original")) original, originalFound := resolution.InputInfo(Keys("values", "original"))
_, destinationFound := resolution.InputInfo(Keys("values", "destination")) _, destinationFound := resolution.InputInfo(Keys("values", "destination"))
existing, existingFound := resolution.InputInfo(Keys("existing")) existing, existingFound := resolution.InputInfo(Keys("existing"))
@@ -475,7 +475,7 @@ func TestResolutionInputQueriesRemainIndependentAcrossLoads(t *testing.T) {
group.Add(1) group.Add(1)
go func() { go func() {
defer group.Done() defer group.Done()
_, err := Load[config](Bytes("parallel.yml", []byte("value: stable\n")), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error { _, err := testLoad[config](Bytes("parallel.yml", []byte("value: stable\n")), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
info, found := resolution.InputInfo(Keys("value")) info, found := resolution.InputInfo(Keys("value"))
source, sourceFound := resolution.Source(info.Origin.Locations[0].SourceID) source, sourceFound := resolution.Source(info.Origin.Locations[0].SourceID)
if !found || !sourceFound || source.ID != "source-1" { if !found || !sourceFound || source.ID != "source-1" {

View File

@@ -85,7 +85,7 @@ sequence: [second, first]
return nil return nil
}, },
} }
result, err := Load[renderConfig](File(mainPath), Options[renderConfig]{Definition: definition, Profile: "testing"}) result, err := testLoad[renderConfig](File(mainPath), Options[renderConfig]{Definition: definition, Profile: "testing"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -160,7 +160,7 @@ func TestSecretValuesStayOutOfLoadFailuresAndWarnings(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
test.definition.Secret = func(path Path) bool { return path.String() == "/credentials" } test.definition.Secret = func(path Path) bool { return path.String() == "/credentials" }
result, err := Load[renderConfig](Bytes("secret-failure.yml", []byte(test.input)), Options[renderConfig]{Definition: test.definition}) result, err := testLoad[renderConfig](Bytes("secret-failure.yml", []byte(test.input)), Options[renderConfig]{Definition: test.definition})
if result != nil || err == nil { if result != nil || err == nil {
t.Fatalf("result, error = %#v, %v", result, err) t.Fatalf("result, error = %#v, %v", result, err)
} }
@@ -205,7 +205,7 @@ func TestDynamicConcreteTagsRemainSecretThroughHistory(t *testing.T) {
Normalize(r, Keys("derived"), &c.Derived, canary, "derive", path) Normalize(r, Keys("derived"), &c.Derived, canary, "derive", path)
return nil return nil
} }
result, err := Load(NoSource(), Options[cfg]{Definition: definition}) result, err := testLoad(NoSource(), Options[cfg]{Definition: definition})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -234,7 +234,7 @@ func TestValidatorCannotChangeDynamicSensitivityWithoutChangingText(t *testing.T
type cfg struct { type cfg struct {
Payload any `yaml:"payload"` Payload any `yaml:"payload"`
} }
_, err := Load(Bytes("base", []byte("payload: {token: disposable}\n")), Options[cfg]{Definition: Definition[cfg]{Validate: func(c *cfg, v *Validation) { _, err := testLoad(Bytes("base", []byte("payload: {token: disposable}\n")), Options[cfg]{Definition: Definition[cfg]{Validate: func(c *cfg, v *Validation) {
c.Payload = sensitive{Token: c.Payload.(map[string]any)["token"].(string)} c.Payload = sensitive{Token: c.Payload.(map[string]any)["token"].(string)}
}}}) }}})
var failure *LoadError var failure *LoadError

View File

@@ -41,7 +41,7 @@ func TestLoadRunsRecordedResolutionWithMapWriteback(t *testing.T) {
}, },
} }
input := "poll_interval: 1m\nartifacts:\n second: {output: ' second '}\n first: {output: ' first '}\n" input := "poll_interval: 1m\nartifacts:\n second: {output: ' second '}\n first: {output: ' first '}\n"
result, err := Load(Bytes("resolution.yml", []byte(input)), Options[config]{Definition: definition}) result, err := testLoad(Bytes("resolution.yml", []byte(input)), Options[config]{Definition: definition})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -94,7 +94,7 @@ func TestResolutionPreservesExplicitNullAndZeroWhileReplacingAbsentBaseline(t *t
Absent int `yaml:"absent"` Absent int `yaml:"absent"`
} }
five := 5 five := 5
result, err := Load(Bytes("explicit.yml", []byte("optional: null\ncount: 0\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad(Bytes("explicit.yml", []byte("optional: null\ncount: 0\n")), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Optional: &five, Count: 5, Absent: 5} }, Defaults: func() config { return config{Optional: &five, Count: 5, Absent: 5} },
Resolve: func(configuration *config, resolution *Resolution) error { Resolve: func(configuration *config, resolution *Resolution) error {
if !resolution.IsExplicit(Keys("optional")) || !resolution.IsExplicit(Keys("count")) || resolution.IsExplicit(Keys("absent")) { if !resolution.IsExplicit(Keys("optional")) || !resolution.IsExplicit(Keys("count")) || resolution.IsExplicit(Keys("absent")) {
@@ -126,7 +126,7 @@ func TestResolutionCoalescesEqualAndRepeatedHelperIntent(t *testing.T) {
Value int `yaml:"value"` Value int `yaml:"value"`
Other int `yaml:"other"` Other int `yaml:"other"`
} }
result, err := Load(NoSource(), Options[config]{Definition: Definition[config]{ result, err := testLoad(NoSource(), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Value: 5} }, Defaults: func() config { return config{Value: 5} },
Resolve: func(configuration *config, resolution *Resolution) error { Resolve: func(configuration *config, resolution *Resolution) error {
Default(resolution, Keys("value"), &configuration.Value, 5, "equal derived intent", Keys("other")) Default(resolution, Keys("value"), &configuration.Value, 5, "equal derived intent", Keys("other"))
@@ -157,7 +157,7 @@ func TestResolutionContainerRecordCoversSubtreeRemovalAndSnapshotsAreDetached(t
type config struct { type config struct {
Items map[string]item `yaml:"items"` Items map[string]item `yaml:"items"`
} }
result, err := Load(Bytes("remove.yml", []byte("items:\n keep: {value: 'yes'}\n remove: {value: 'no'}\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad(Bytes("remove.yml", []byte("items:\n keep: {value: 'yes'}\n remove: {value: 'no'}\n")), Options[config]{Definition: Definition[config]{
Resolve: func(configuration *config, resolution *Resolution) error { Resolve: func(configuration *config, resolution *Resolution) error {
delete(configuration.Items, "remove") delete(configuration.Items, "remove")
resolution.Record(Keys("items"), ChangeNormalization, "removed disabled entries") resolution.Record(Keys("items"), ChangeNormalization, "removed disabled entries")
@@ -188,7 +188,7 @@ func TestResolutionSequenceReplacementUpdatesDescendantOrigins(t *testing.T) {
type config struct { type config struct {
Values []int `yaml:"values"` Values []int `yaml:"values"`
} }
result, err := Load(Bytes("sequence.yml", []byte("values: [1, 2]\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad(Bytes("sequence.yml", []byte("values: [1, 2]\n")), Options[config]{Definition: Definition[config]{
Resolve: func(configuration *config, resolution *Resolution) error { Resolve: func(configuration *config, resolution *Resolution) error {
Normalize(resolution, Keys("values"), &configuration.Values, []int{3}, "replaced values") Normalize(resolution, Keys("values"), &configuration.Values, []int{3}, "replaced values")
return nil return nil
@@ -237,7 +237,7 @@ func TestResolutionRejectsUnrecordedAndMismatchedMutations(t *testing.T) {
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
result, err := Load(Bytes("invalid-resolution.yml", []byte("value: 1\n")), Options[config]{Definition: Definition[config]{Resolve: test.resolve}}) result, err := testLoad(Bytes("invalid-resolution.yml", []byte("value: 1\n")), Options[config]{Definition: Definition[config]{Resolve: test.resolve}})
assertLoadDiagnostic(t, result, err, test.code) assertLoadDiagnostic(t, result, err, test.code)
}) })
} }
@@ -269,7 +269,7 @@ func TestResolutionRejectsInvalidProjectedValuesAndSuppressesReturnedErrors(t *t
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
result, err := Load(Bytes("resolution-errors.yml", []byte("value: 1\nnumber: 1\n")), Options[config]{Definition: Definition[config]{Resolve: test.resolve}}) result, err := testLoad(Bytes("resolution-errors.yml", []byte("value: 1\nnumber: 1\n")), Options[config]{Definition: Definition[config]{Resolve: test.resolve}})
loadError := assertLoadDiagnostic(t, result, err, test.code) loadError := assertLoadDiagnostic(t, result, err, test.code)
for _, diagnostic := range loadError.Diagnostics() { for _, diagnostic := range loadError.Diagnostics() {
if strings.Contains(diagnostic.Message, "CANARY") { if strings.Contains(diagnostic.Message, "CANARY") {
@@ -291,7 +291,7 @@ func TestResolutionEntryQueriesExposeDetachedLoadInput(t *testing.T) {
filename := writeImportFile(t, t.TempDir(), "config.yml", "declared: file\noptional: null\n") filename := writeImportFile(t, t.TempDir(), "config.yml", "declared: file\noptional: null\n")
var savedField FieldInfo var savedField FieldInfo
var savedSource SourceInfo var savedSource SourceInfo
result, err := Load[config](File(filename), Options[config]{Definition: Definition[config]{ result, err := testLoad[config](File(filename), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Default: "retained"} }, Defaults: func() config { return config{Default: "retained"} },
Resolve: func(_ *config, resolution *Resolution) error { Resolve: func(_ *config, resolution *Resolution) error {
for _, path := range []Path{Keys("declared"), Keys("default"), Keys("optional")} { for _, path := range []Path{Keys("declared"), Keys("default"), Keys("optional")} {
@@ -339,7 +339,7 @@ func TestResolutionEntryQueriesExposeDetachedLoadInput(t *testing.T) {
t.Fatal("saved resolution records alias a completed result") t.Fatal("saved resolution records alias a completed result")
} }
noSource, err := Load[config](NoSource(), Options[config]{Definition: Definition[config]{ noSource, err := testLoad[config](NoSource(), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Default: "retained"} }, Defaults: func() config { return config{Default: "retained"} },
Resolve: func(_ *config, resolution *Resolution) error { Resolve: func(_ *config, resolution *Resolution) error {
info, found := resolution.InputInfo(Keys("default")) info, found := resolution.InputInfo(Keys("default"))
@@ -364,7 +364,7 @@ func TestResolutionContextPanicsAfterCallback(t *testing.T) {
Value int `yaml:"value"` Value int `yaml:"value"`
} }
var retained *Resolution var retained *Resolution
if _, err := Load(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error { if _, err := testLoad(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
retained = resolution retained = resolution
return nil return nil
}}}); err != nil { }}}); err != nil {
@@ -397,7 +397,7 @@ func TestResolutionEntryQueriesRequireAnActiveCallback(t *testing.T) {
requireClosed(t, func() { (&Resolution{}).Source("source-1") }) requireClosed(t, func() { (&Resolution{}).Source("source-1") })
var successful *Resolution var successful *Resolution
if _, err := Load(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error { if _, err := testLoad(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
successful = resolution successful = resolution
return nil return nil
}}}); err != nil { }}}); err != nil {
@@ -407,7 +407,7 @@ func TestResolutionEntryQueriesRequireAnActiveCallback(t *testing.T) {
requireClosed(t, func() { successful.Source("source-1") }) requireClosed(t, func() { successful.Source("source-1") })
var failed *Resolution var failed *Resolution
if _, err := Load(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error { if _, err := testLoad(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
failed = resolution failed = resolution
return errors.New("resolver failed") return errors.New("resolver failed")
}}}); err == nil { }}}); err == nil {
@@ -424,7 +424,7 @@ func TestValidationContextPanicsAfterVersionCallback(t *testing.T) {
Version string `yaml:"version"` Version string `yaml:"version"`
} }
var retained *Validation var retained *Validation
if _, err := Load(Bytes("version-context.yml", []byte("version: one\n")), Options[config]{Definition: Definition[config]{ if _, err := testLoad(Bytes("version-context.yml", []byte("version: one\n")), Options[config]{Definition: Definition[config]{
Version: &VersionRule{Path: Keys("version"), Check: func(_ Version, validation *Validation) error { Version: &VersionRule{Path: Keys("version"), Check: func(_ Version, validation *Validation) error {
retained = validation retained = validation
return nil return nil

View File

@@ -29,8 +29,9 @@ const (
) )
type schemaDescriptor struct { type schemaDescriptor struct {
root *typeDescriptor root *typeDescriptor
types map[reflect.Type]*typeDescriptor types map[reflect.Type]*typeDescriptor
secrets SecretBinding
} }
type typeDescriptor struct { type typeDescriptor struct {
@@ -65,20 +66,39 @@ type schemaIssue struct {
message string message string
} }
// SecretState is the private operation state for one exact-type secret value.
// It is supplied by the facade and never enters inspection records.
type SecretState struct {
Name string
Value string
Specified bool
Resolved bool
}
// SecretBinding adapts the facade-owned exact secret type for one load.
// Bindings are operation data: callers must supply the type and conversions
// together, and a binding never becomes package-global state.
type SecretBinding struct {
Type reflect.Type
Read func(reflect.Value) SecretState
Make func(SecretState) reflect.Value
}
var ( var (
durationType = reflect.TypeFor[time.Duration]() durationType = reflect.TypeFor[time.Duration]()
secretType = reflect.TypeFor[Secret]()
textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]() textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]()
textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]() textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]()
) )
func buildSchema(root reflect.Type) (*schemaDescriptor, *schemaIssue) { func buildSchema(root reflect.Type, secrets SecretBinding) (*schemaDescriptor, *schemaIssue) {
secrets.validate()
if root == nil || (root.Kind() != reflect.Struct && !isStringMap(root)) { if root == nil || (root.Kind() != reflect.Struct && !isStringMap(root)) {
return nil, &schemaIssue{code: CodeSchemaRoot, message: "configuration root must be a struct or string-keyed map"} return nil, &schemaIssue{code: CodeSchemaRoot, message: "configuration root must be a struct or string-keyed map"}
} }
builder := schemaBuilder{ builder := schemaBuilder{
cache: make(map[reflect.Type]*typeDescriptor), cache: make(map[reflect.Type]*typeDescriptor),
building: make(map[reflect.Type]bool), building: make(map[reflect.Type]bool),
secrets: secrets,
} }
descriptor, issue := builder.describe(root, Path{}) descriptor, issue := builder.describe(root, Path{})
if issue != nil { if issue != nil {
@@ -87,12 +107,13 @@ func buildSchema(root reflect.Type) (*schemaDescriptor, *schemaIssue) {
if descriptor.kind != descriptorStruct && descriptor.kind != descriptorMap { if descriptor.kind != descriptorStruct && descriptor.kind != descriptorMap {
return nil, &schemaIssue{code: CodeSchemaRoot, message: "configuration root must be a structural struct or string-keyed map"} return nil, &schemaIssue{code: CodeSchemaRoot, message: "configuration root must be a structural struct or string-keyed map"}
} }
return &schemaDescriptor{root: descriptor, types: builder.cache}, nil return &schemaDescriptor{root: descriptor, types: builder.cache, secrets: secrets}, nil
} }
type schemaBuilder struct { type schemaBuilder struct {
cache map[reflect.Type]*typeDescriptor cache map[reflect.Type]*typeDescriptor
building map[reflect.Type]bool building map[reflect.Type]bool
secrets SecretBinding
} }
func (b *schemaBuilder) describe(current reflect.Type, path Path) (*typeDescriptor, *schemaIssue) { func (b *schemaBuilder) describe(current reflect.Type, path Path) (*typeDescriptor, *schemaIssue) {
@@ -122,7 +143,7 @@ func (b *schemaBuilder) describe(current reflect.Type, path Path) (*typeDescript
descriptor.kind = descriptorDuration descriptor.kind = descriptorDuration
return descriptor, nil return descriptor, nil
} }
if current == secretType { if b.secrets.Type != nil && current == b.secrets.Type {
descriptor.kind = descriptorSecret descriptor.kind = descriptorSecret
return descriptor, nil return descriptor, nil
} }
@@ -189,6 +210,15 @@ func (b *schemaBuilder) describe(current reflect.Type, path Path) (*typeDescript
return descriptor, nil return descriptor, nil
} }
func (binding SecretBinding) validate() {
if binding.Type == nil {
return
}
if binding.Read == nil || binding.Make == nil {
panic("configkit engine: incomplete secret binding")
}
}
func (b *schemaBuilder) describeFields(descriptor *typeDescriptor, current reflect.Type, path Path) *schemaIssue { func (b *schemaBuilder) describeFields(descriptor *typeDescriptor, current reflect.Type, path Path) *schemaIssue {
for i := 0; i < current.NumField(); i++ { for i := 0; i < current.NumField(); i++ {
field := current.Field(i) field := current.Field(i)

View File

@@ -68,9 +68,9 @@ func TestBuildSchemaDescribesSupportedTypes(t *testing.T) {
private chan int private chan int
} }
schema, issue := buildSchema(reflect.TypeFor[config]()) schema, issue := testBuildSchema(reflect.TypeFor[config]())
if issue != nil { if issue != nil {
t.Fatalf("buildSchema() issue = %#v", issue) t.Fatalf("testBuildSchema() issue = %#v", issue)
} }
root := schema.root root := schema.root
checks := map[string]descriptorKind{ checks := map[string]descriptorKind{
@@ -105,7 +105,7 @@ func TestBuildSchemaDescribesSupportedTypes(t *testing.T) {
if root.inlineMap == nil || root.inlineMap.descriptor.kind != descriptorMap { if root.inlineMap == nil || root.inlineMap.descriptor.kind != descriptorMap {
t.Fatalf("inline map = %#v", root.inlineMap) t.Fatalf("inline map = %#v", root.inlineMap)
} }
if mapped, mapIssue := buildSchema(reflect.TypeFor[map[string]nested]()); mapIssue != nil || mapped.root.kind != descriptorMap { if mapped, mapIssue := testBuildSchema(reflect.TypeFor[map[string]nested]()); mapIssue != nil || mapped.root.kind != descriptorMap {
t.Fatalf("string-keyed root map = %#v, issue=%#v", mapped, mapIssue) t.Fatalf("string-keyed root map = %#v, issue=%#v", mapped, mapIssue)
} }
if _, exists := root.fieldByName["ignored"]; exists { if _, exists := root.fieldByName["ignored"]; exists {
@@ -116,7 +116,7 @@ func TestBuildSchemaDescribesSupportedTypes(t *testing.T) {
func TestBuildSchemaSupportsRecursiveDescriptorsWithoutSharingLoads(t *testing.T) { func TestBuildSchemaSupportsRecursiveDescriptorsWithoutSharingLoads(t *testing.T) {
t.Parallel() t.Parallel()
first, issue := buildSchema(reflect.TypeFor[recursiveConfig]()) first, issue := testBuildSchema(reflect.TypeFor[recursiveConfig]())
if issue != nil { if issue != nil {
t.Fatal(issue.message) t.Fatal(issue.message)
} }
@@ -128,7 +128,7 @@ func TestBuildSchemaSupportsRecursiveDescriptorsWithoutSharingLoads(t *testing.T
if children.kind != descriptorMap || children.element != first.root { if children.kind != descriptorMap || children.element != first.root {
t.Fatal("recursive map did not refer to the existing descriptor") t.Fatal("recursive map did not refer to the existing descriptor")
} }
second, issue := buildSchema(reflect.TypeFor[recursiveConfig]()) second, issue := testBuildSchema(reflect.TypeFor[recursiveConfig]())
if issue != nil { if issue != nil {
t.Fatal(issue.message) t.Fatal(issue.message)
} }
@@ -207,7 +207,7 @@ func TestBuildSchemaRejectsInvalidRootsTypesTagsAndBindings(t *testing.T) {
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
if _, issue := buildSchema(test.typeOf); issue == nil || issue.code != test.code { if _, issue := testBuildSchema(test.typeOf); issue == nil || issue.code != test.code {
t.Fatalf("issue = %#v, want %s", issue, test.code) t.Fatalf("issue = %#v, want %s", issue, test.code)
} }
}) })
@@ -221,7 +221,7 @@ func TestBuildSchemaDoesNotExecuteApplicationMethods(t *testing.T) {
Value panicText `yaml:"value"` Value panicText `yaml:"value"`
Structural yamlHookStruct `yaml:"structural"` Structural yamlHookStruct `yaml:"structural"`
} }
if _, issue := buildSchema(reflect.TypeFor[config]()); issue != nil { if _, issue := testBuildSchema(reflect.TypeFor[config]()); issue != nil {
t.Fatal(issue.message) t.Fatal(issue.message)
} }
} }
@@ -261,7 +261,7 @@ func TestResolveSchemaPathUsesContainerSemantics(t *testing.T) {
Dynamic any `yaml:"dynamic"` Dynamic any `yaml:"dynamic"`
Extras map[string]string `yaml:",inline"` Extras map[string]string `yaml:",inline"`
} }
schema, issue := buildSchema(reflect.TypeFor[config]()) schema, issue := testBuildSchema(reflect.TypeFor[config]())
if issue != nil { if issue != nil {
t.Fatal(issue.message) t.Fatal(issue.message)
} }
@@ -310,7 +310,7 @@ func TestInlineMapPointersFailBeforeDefaults(t *testing.T) {
} }
for _, nonNil := range []bool{false, true} { for _, nonNil := range []bool{false, true} {
called := false called := false
_, err := Load(NoSource(), Options[cfg]{Definition: Definition[cfg]{Defaults: func() cfg { _, err := testLoad(NoSource(), Options[cfg]{Definition: Definition[cfg]{Defaults: func() cfg {
called = true called = true
if nonNil { if nonNil {
m := map[string]int{"value": 1} m := map[string]int{"value": 1}
@@ -330,13 +330,13 @@ func TestInlineMapPointersFailBeforeDefaults(t *testing.T) {
type recursive struct { type recursive struct {
Extra pointer `yaml:",inline"` Extra pointer `yaml:",inline"`
} }
if _, err := Load[recursive](NoSource()); err == nil { if _, err := testLoad[recursive](NoSource()); err == nil {
t.Fatal("recursive inline pointer was accepted") t.Fatal("recursive inline pointer was accepted")
} }
type double struct { type double struct {
Extra **map[string]int `yaml:",inline"` Extra **map[string]int `yaml:",inline"`
} }
if _, err := Load[double](NoSource()); err == nil { if _, err := testLoad[double](NoSource()); err == nil {
t.Fatal("double inline map pointer was accepted") t.Fatal("double inline map pointer was accepted")
} }
} }

View File

@@ -0,0 +1,107 @@
package engine
import (
"encoding/json"
"fmt"
"io"
"reflect"
"strconv"
"testing"
)
// Secret is a test-local facade secret. Production engine code must not depend
// on this type; each test load supplies the binding below explicitly.
type Secret struct {
name string
value string
specified bool
resolved bool
}
func SecretEnv(name string) Secret {
return Secret{name: name, specified: true}
}
func (s Secret) EnvName() string {
return s.name
}
func (s Secret) Value() string {
if !s.resolved {
return ""
}
return s.value
}
func (s Secret) IsResolved() bool {
return s.resolved
}
func (s Secret) Format(state fmt.State, _ rune) {
_, _ = io.WriteString(state, "configkit.SecretEnv("+strconv.Quote(s.name)+")")
}
func (s Secret) MarshalJSON() ([]byte, error) {
return json.Marshal(s.name)
}
func testSecretBinding() SecretBinding {
return SecretBinding{
Type: reflect.TypeFor[Secret](),
Read: func(value reflect.Value) SecretState {
secret := value.Interface().(Secret)
return SecretState{Name: secret.name, Value: secret.value, Specified: secret.specified, Resolved: secret.resolved}
},
Make: func(state SecretState) reflect.Value {
return reflect.ValueOf(Secret{name: state.Name, value: state.Value, specified: state.Specified, resolved: state.Resolved})
},
}
}
func testLoad[T any](source Source, supplied ...Options[T]) (*Result[T], error) {
return Load(source, testSecretBinding(), supplied...)
}
func testLoadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ...Options[T]) (*Result[T], error) {
return loadWithSecretLookup(source, testSecretBinding(), lookup, supplied...)
}
func testBuildSchema(root reflect.Type) (*schemaDescriptor, *schemaIssue) {
return buildSchema(root, testSecretBinding())
}
func TestSecretBindingsRemainExactAndIndependent(t *testing.T) {
t.Parallel()
type otherSecret struct {
name string
}
other := SecretBinding{
Type: reflect.TypeFor[otherSecret](),
Read: func(value reflect.Value) SecretState {
return SecretState{Name: value.Interface().(otherSecret).name}
},
Make: func(state SecretState) reflect.Value {
return reflect.ValueOf(otherSecret{name: state.Name})
},
}
type config struct {
First Secret `yaml:"first"`
Second otherSecret `yaml:"second"`
}
firstSchema, firstIssue := buildSchema(reflect.TypeFor[config](), testSecretBinding())
if firstIssue != nil {
t.Fatal(firstIssue)
}
secondSchema, secondIssue := buildSchema(reflect.TypeFor[config](), other)
if secondIssue != nil {
t.Fatal(secondIssue)
}
firstField := firstSchema.root.fields[firstSchema.root.fieldByName["first"]].descriptor
firstOther := firstSchema.root.fields[firstSchema.root.fieldByName["second"]].descriptor
secondField := secondSchema.root.fields[secondSchema.root.fieldByName["first"]].descriptor
secondOther := secondSchema.root.fields[secondSchema.root.fieldByName["second"]].descriptor
if firstField.kind != descriptorSecret || firstOther.kind == descriptorSecret || secondField.kind == descriptorSecret || secondOther.kind != descriptorSecret {
t.Fatalf("secret descriptor kinds = %v, %v, %v, %v", firstField.kind, firstOther.kind, secondField.kind, secondOther.kind)
}
}

View File

@@ -2,60 +2,12 @@ package engine
import ( import (
"cmp" "cmp"
"encoding/json"
"fmt"
"io"
"reflect" "reflect"
"slices" "slices"
"strconv"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
) )
// Secret is an opaque reference to an environment variable. Its name is public
// configuration; its resolved value is available only through Value.
type Secret struct {
name string
value string
specified bool
resolved bool
}
// SecretEnv constructs an unresolved environment-backed secret reference.
// Invalid names are reported if the reference survives a configuration load.
func SecretEnv(name string) Secret {
return Secret{name: name, specified: true}
}
// EnvName returns the public environment-variable name.
func (s Secret) EnvName() string {
return s.name
}
// Value returns the resolved credential, or an empty string when unresolved.
func (s Secret) Value() string {
if !s.resolved {
return ""
}
return s.value
}
// IsResolved reports whether this reference resolved to a nonempty value during
// its configuration load.
func (s Secret) IsResolved() bool {
return s.resolved
}
// Format renders only the public reference name for every formatting verb.
func (s Secret) Format(state fmt.State, _ rune) {
_, _ = io.WriteString(state, "configkit.SecretEnv("+strconv.Quote(s.name)+")")
}
// MarshalJSON emits only the public environment-variable name.
func (s Secret) MarshalJSON() ([]byte, error) {
return json.Marshal(s.name)
}
type secretLookup func(string) (string, bool) type secretLookup func(string) (string, bool)
type secretLookupResult struct { type secretLookupResult struct {
@@ -80,28 +32,28 @@ func resolveSecretReferences(root reflect.Value, schema *schemaDescriptor, input
if lookup == nil { if lookup == nil {
lookup = func(string) (string, bool) { return "", false } lookup = func(string) (string, bool) { return "", false }
} }
resolveSecretValue(root, schema.root, Path{}, false, input, lookup, &result) resolveSecretValue(root, schema.root, schema.secrets, Path{}, false, input, lookup, &result)
result.diagnostics = canonicalDiagnostics(result.diagnostics) result.diagnostics = canonicalDiagnostics(result.diagnostics)
return result return result
} }
func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Path, optional bool, input *document.Node, lookup secretLookup, result *secretResolutionResult) { func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, secrets SecretBinding, path Path, optional bool, input *document.Node, lookup secretLookup, result *secretResolutionResult) {
if descriptor.kind == descriptorPointer { if descriptor.kind == descriptorPointer {
if value.IsNil() { if value.IsNil() {
return return
} }
resolveSecretValue(value.Elem(), descriptor.element, path, descriptor.element.kind == descriptorSecret, input, lookup, result) resolveSecretValue(value.Elem(), descriptor.element, secrets, path, descriptor.element.kind == descriptorSecret, input, lookup, result)
return return
} }
switch descriptor.kind { switch descriptor.kind {
case descriptorSecret: case descriptorSecret:
reference := value.Interface().(Secret) reference := secrets.Read(value)
name := reference.EnvName() name := reference.Name
if name == "" { if name == "" {
code := CodeSecretRequired code := CodeSecretRequired
message := "required secret reference is absent" message := "required secret reference is absent"
if optional || reference.specified { if optional || reference.Specified {
code = CodeSecretInvalidName code = CodeSecretInvalidName
message = "secret environment name is empty" message = "secret environment name is empty"
} }
@@ -125,7 +77,7 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Pa
result.diagnostics = append(result.diagnostics, secretDiagnostic(CodeSecretEmpty, path, "secret environment variable is empty", input)) result.diagnostics = append(result.diagnostics, secretDiagnostic(CodeSecretEmpty, path, "secret environment variable is empty", input))
return return
} }
value.Set(reflect.ValueOf(Secret{name: name, value: found.value, specified: true, resolved: true})) value.Set(secrets.Make(SecretState{Name: name, Value: found.value, Specified: true, Resolved: true}))
result.references[path.String()] = name result.references[path.String()] = name
case descriptorStruct: case descriptorStruct:
for _, member := range descriptor.members { for _, member := range descriptor.members {
@@ -133,7 +85,7 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Pa
if !member.inline { if !member.inline {
memberPath = path.Key(member.name) memberPath = path.Key(member.name)
} }
resolveSecretValue(value.Field(member.index), member.descriptor, memberPath, false, input, lookup, result) resolveSecretValue(value.Field(member.index), member.descriptor, secrets, memberPath, false, input, lookup, result)
} }
case descriptorMap: case descriptorMap:
if value.IsNil() { if value.IsNil() {
@@ -144,7 +96,7 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Pa
for _, key := range keys { for _, key := range keys {
entry := reflect.New(value.Type().Elem()).Elem() entry := reflect.New(value.Type().Elem()).Elem()
entry.Set(value.MapIndex(key)) entry.Set(value.MapIndex(key))
resolveSecretValue(entry, descriptor.element, path.Key(key.String()), false, input, lookup, result) resolveSecretValue(entry, descriptor.element, secrets, path.Key(key.String()), false, input, lookup, result)
value.SetMapIndex(key, entry) value.SetMapIndex(key, entry)
} }
case descriptorSlice, descriptorArray: case descriptorSlice, descriptorArray:
@@ -152,7 +104,7 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Pa
return return
} }
for index := 0; index < value.Len(); index++ { for index := 0; index < value.Len(); index++ {
resolveSecretValue(value.Index(index), descriptor.element, path.Index(index), false, input, lookup, result) resolveSecretValue(value.Index(index), descriptor.element, secrets, path.Index(index), false, input, lookup, result)
} }
} }
} }
@@ -165,25 +117,28 @@ func secretDiagnostic(code Code, path Path, message string, input *document.Node
return diagnostic return diagnostic
} }
func changedSecretStates(root reflect.Value, descriptor *typeDescriptor, lookups map[string]secretLookupResult) []Path { func changedSecretStates(root reflect.Value, schema *schemaDescriptor, lookups map[string]secretLookupResult) []Path {
var changed []Path var changed []Path
collectChangedSecretStates(root, descriptor, Path{}, lookups, &changed) if schema == nil || schema.root == nil {
return nil
}
collectChangedSecretStates(root, schema.root, schema.secrets, Path{}, lookups, &changed)
slices.SortFunc(changed, func(left, right Path) int { return cmp.Compare(left.String(), right.String()) }) slices.SortFunc(changed, func(left, right Path) int { return cmp.Compare(left.String(), right.String()) })
return changed return changed
} }
func collectChangedSecretStates(value reflect.Value, descriptor *typeDescriptor, path Path, lookups map[string]secretLookupResult, changed *[]Path) { func collectChangedSecretStates(value reflect.Value, descriptor *typeDescriptor, secrets SecretBinding, path Path, lookups map[string]secretLookupResult, changed *[]Path) {
if descriptor.kind == descriptorPointer { if descriptor.kind == descriptorPointer {
if !value.IsNil() { if !value.IsNil() {
collectChangedSecretStates(value.Elem(), descriptor.element, path, lookups, changed) collectChangedSecretStates(value.Elem(), descriptor.element, secrets, path, lookups, changed)
} }
return return
} }
switch descriptor.kind { switch descriptor.kind {
case descriptorSecret: case descriptorSecret:
reference := value.Interface().(Secret) reference := secrets.Read(value)
expected, ok := lookups[reference.EnvName()] expected, ok := lookups[reference.Name]
if ok && (!expected.present || expected.value == "" || !reference.resolved || reference.value != expected.value) { if ok && (!expected.present || expected.value == "" || !reference.Resolved || reference.Value != expected.value) {
*changed = append(*changed, path.clone()) *changed = append(*changed, path.clone())
} }
case descriptorStruct: case descriptorStruct:
@@ -192,7 +147,7 @@ func collectChangedSecretStates(value reflect.Value, descriptor *typeDescriptor,
if !member.inline { if !member.inline {
memberPath = path.Key(member.name) memberPath = path.Key(member.name)
} }
collectChangedSecretStates(value.Field(member.index), member.descriptor, memberPath, lookups, changed) collectChangedSecretStates(value.Field(member.index), member.descriptor, secrets, memberPath, lookups, changed)
} }
case descriptorMap: case descriptorMap:
if value.IsNil() { if value.IsNil() {
@@ -201,14 +156,14 @@ func collectChangedSecretStates(value reflect.Value, descriptor *typeDescriptor,
keys := value.MapKeys() keys := value.MapKeys()
slices.SortFunc(keys, func(left, right reflect.Value) int { return cmp.Compare(left.String(), right.String()) }) slices.SortFunc(keys, func(left, right reflect.Value) int { return cmp.Compare(left.String(), right.String()) })
for _, key := range keys { for _, key := range keys {
collectChangedSecretStates(value.MapIndex(key), descriptor.element, path.Key(key.String()), lookups, changed) collectChangedSecretStates(value.MapIndex(key), descriptor.element, secrets, path.Key(key.String()), lookups, changed)
} }
case descriptorSlice, descriptorArray: case descriptorSlice, descriptorArray:
if descriptor.kind == descriptorSlice && value.IsNil() { if descriptor.kind == descriptorSlice && value.IsNil() {
return return
} }
for index := 0; index < value.Len(); index++ { for index := 0; index < value.Len(); index++ {
collectChangedSecretStates(value.Index(index), descriptor.element, path.Index(index), lookups, changed) collectChangedSecretStates(value.Index(index), descriptor.element, secrets, path.Index(index), lookups, changed)
} }
} }
} }

View File

@@ -51,7 +51,7 @@ sequence: [SHARED_SECRET, SEQUENCE_SECRET]
return value, present return value, present
} }
validationCalled := false validationCalled := false
result, err := loadWithSecretLookup(Bytes("secrets.yml", []byte(input)), lookup, Options[secretReferenceConfig]{ result, err := testLoadWithSecretLookup(Bytes("secrets.yml", []byte(input)), lookup, Options[secretReferenceConfig]{
Definition: Definition[secretReferenceConfig]{Validate: func(configuration *secretReferenceConfig, validation *Validation) { Definition: Definition[secretReferenceConfig]{Validate: func(configuration *secretReferenceConfig, validation *Validation) {
validationCalled = true validationCalled = true
if configuration.Required.Value() != "shared-value" || !configuration.Required.IsResolved() || if configuration.Required.Value() != "shared-value" || !configuration.Required.IsResolved() ||
@@ -62,7 +62,7 @@ sequence: [SHARED_SECRET, SEQUENCE_SECRET]
}}, }},
}) })
if err != nil { if err != nil {
t.Fatalf("loadWithSecretLookup() error = %v", err) t.Fatalf("testLoadWithSecretLookup() error = %v", err)
} }
if !validationCalled || result.Config.Optional == nil || result.Config.Pointers["nil"] != nil { if !validationCalled || result.Config.Optional == nil || result.Config.Pointers["nil"] != nil {
t.Fatalf("resolved config = %#v", result.Config) t.Fatalf("resolved config = %#v", result.Config)
@@ -94,7 +94,7 @@ func TestSecretReferenceErrorsAreSafeAndPathSpecific(t *testing.T) {
if lookup == nil { if lookup == nil {
lookup = func(string) (string, bool) { return "", false } lookup = func(string) (string, bool) { return "", false }
} }
_, err := loadWithSecretLookup(Bytes("secret-errors.yml", []byte(test.input)), lookup, Options[secretReferenceConfig]{}) _, err := testLoadWithSecretLookup(Bytes("secret-errors.yml", []byte(test.input)), lookup, Options[secretReferenceConfig]{})
loadError := requireSecretLoadCode(t, err, test.code) loadError := requireSecretLoadCode(t, err, test.code)
diagnostic := loadError.Diagnostics()[0] diagnostic := loadError.Diagnostics()[0]
if diagnostic.Path.String() != "/required" || strings.Contains(fmt.Sprintf("%v %#v", err, err), "CREDENTIAL-CANARY") { if diagnostic.Path.String() != "/required" || strings.Contains(fmt.Sprintf("%v %#v", err, err), "CREDENTIAL-CANARY") {
@@ -107,9 +107,9 @@ func TestSecretReferenceErrorsAreSafeAndPathSpecific(t *testing.T) {
type config struct { type config struct {
Optional *Secret `yaml:"optional"` Optional *Secret `yaml:"optional"`
} }
_, err := loadWithSecretLookup(Bytes("optional.yml", []byte("optional: ''\n")), func(string) (string, bool) { return "", false }, Options[config]{}) _, err := testLoadWithSecretLookup(Bytes("optional.yml", []byte("optional: ''\n")), func(string) (string, bool) { return "", false }, Options[config]{})
requireSecretLoadCode(t, err, CodeSecretInvalidName) requireSecretLoadCode(t, err, CodeSecretInvalidName)
result, err := loadWithSecretLookup(Bytes("optional.yml", []byte("optional: null\n")), func(string) (string, bool) { return "", false }, Options[config]{}) result, err := testLoadWithSecretLookup(Bytes("optional.yml", []byte("optional: null\n")), func(string) (string, bool) { return "", false }, Options[config]{})
if err != nil || result.Config.Optional != nil { if err != nil || result.Config.Optional != nil {
t.Fatalf("nil optional result = %#v, %v", result, err) t.Fatalf("nil optional result = %#v, %v", result, err)
} }
@@ -120,7 +120,7 @@ func TestSecretReferenceErrorsAreSafeAndPathSpecific(t *testing.T) {
Secrets []Secret `yaml:"secrets"` Secrets []Secret `yaml:"secrets"`
} }
lookups := 0 lookups := 0
_, err := loadWithSecretLookup(Bytes("missing-twice.yml", []byte("secrets: [MISSING_SHARED, MISSING_SHARED]\n")), func(string) (string, bool) { _, err := testLoadWithSecretLookup(Bytes("missing-twice.yml", []byte("secrets: [MISSING_SHARED, MISSING_SHARED]\n")), func(string) (string, bool) {
lookups++ lookups++
return "", false return "", false
}, Options[config]{}) }, Options[config]{})
@@ -138,7 +138,7 @@ func TestResolutionSelectsEffectiveSecretNameBeforeLookup(t *testing.T) {
} }
lookedUp := []string{} lookedUp := []string{}
validated := "" validated := ""
result, err := loadWithSecretLookup(Bytes("resolved-secret.yml", []byte("name: SELECTED_SECRET\nsecret: OLD_SECRET\n")), func(name string) (string, bool) { result, err := testLoadWithSecretLookup(Bytes("resolved-secret.yml", []byte("name: SELECTED_SECRET\nsecret: OLD_SECRET\n")), func(name string) (string, bool) {
lookedUp = append(lookedUp, name) lookedUp = append(lookedUp, name)
return "resolved-value", true return "resolved-value", true
}, Options[config]{Definition: Definition[config]{ }, Options[config]{Definition: Definition[config]{
@@ -151,7 +151,7 @@ func TestResolutionSelectsEffectiveSecretNameBeforeLookup(t *testing.T) {
}, },
}}) }})
if err != nil { if err != nil {
t.Fatalf("loadWithSecretLookup() error = %v", err) t.Fatalf("testLoadWithSecretLookup() error = %v", err)
} }
if !reflect.DeepEqual(lookedUp, []string{"SELECTED_SECRET"}) || validated != "resolved-value" || result.Config.Secret.EnvName() != "SELECTED_SECRET" { if !reflect.DeepEqual(lookedUp, []string{"SELECTED_SECRET"}) || validated != "resolved-value" || result.Config.Secret.EnvName() != "SELECTED_SECRET" {
t.Fatalf("lookups = %#v, validated = %q, config = %#v", lookedUp, validated, result.Config) t.Fatalf("lookups = %#v, validated = %q, config = %#v", lookedUp, validated, result.Config)
@@ -172,7 +172,7 @@ func TestResolutionInputQueriesDoNotReadOrResolveSecrets(t *testing.T) {
reader := &resolutionQueryReader{input: strings.NewReader("value: source\nsecret: QUERY_SECRET\n")} reader := &resolutionQueryReader{input: strings.NewReader("value: source\nsecret: QUERY_SECRET\n")}
lookups := 0 lookups := 0
var callbackErr error var callbackErr error
result, err := loadWithSecretLookup(Reader("query.yml", reader), func(name string) (string, bool) { result, err := testLoadWithSecretLookup(Reader("query.yml", reader), func(name string) (string, bool) {
lookups++ lookups++
if name != "QUERY_SECRET" { if name != "QUERY_SECRET" {
t.Fatalf("secret lookup name = %q", name) t.Fatalf("secret lookup name = %q", name)
@@ -210,7 +210,7 @@ func TestValidationCannotReplaceResolvedSecretWithSameName(t *testing.T) {
type config struct { type config struct {
Secret Secret `yaml:"secret"` Secret Secret `yaml:"secret"`
} }
_, err := loadWithSecretLookup(Bytes("validator.yml", []byte("secret: VALIDATOR_SECRET\n")), func(string) (string, bool) { _, err := testLoadWithSecretLookup(Bytes("validator.yml", []byte("secret: VALIDATOR_SECRET\n")), func(string) (string, bool) {
return "CREDENTIAL-CANARY", true return "CREDENTIAL-CANARY", true
}, Options[config]{Definition: Definition[config]{Validate: func(configuration *config, validation *Validation) { }, Options[config]{Definition: Definition[config]{Validate: func(configuration *config, validation *Validation) {
configuration.Secret = SecretEnv(configuration.Secret.EnvName()) configuration.Secret = SecretEnv(configuration.Secret.EnvName())
@@ -225,13 +225,13 @@ func TestResolvedDefaultsAreRebuiltAndLaterLoadsRelookup(t *testing.T) {
type config struct { type config struct {
Secret Secret `yaml:"secret"` Secret Secret `yaml:"secret"`
} }
first, err := loadWithSecretLookup(NoSource(), func(string) (string, bool) { return "first-value", true }, Options[config]{ first, err := testLoadWithSecretLookup(NoSource(), func(string) (string, bool) { return "first-value", true }, Options[config]{
Definition: Definition[config]{Defaults: func() config { return config{Secret: SecretEnv("DEFAULT_SECRET")} }}, Definition: Definition[config]{Defaults: func() config { return config{Secret: SecretEnv("DEFAULT_SECRET")} }},
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
second, err := loadWithSecretLookup(NoSource(), func(string) (string, bool) { return "second-value", true }, Options[config]{ second, err := testLoadWithSecretLookup(NoSource(), func(string) (string, bool) { return "second-value", true }, Options[config]{
Definition: Definition[config]{Defaults: func() config { return first.Config }}, Definition: Definition[config]{Defaults: func() config { return first.Config }},
}) })
if err != nil { if err != nil {
@@ -243,14 +243,14 @@ func TestResolvedDefaultsAreRebuiltAndLaterLoadsRelookup(t *testing.T) {
t.Setenv("CONFIGKIT_RELOOKUP_SECRET", "one") t.Setenv("CONFIGKIT_RELOOKUP_SECRET", "one")
options := Options[config]{Definition: Definition[config]{Defaults: func() config { return config{Secret: SecretEnv("CONFIGKIT_RELOOKUP_SECRET")} }}} options := Options[config]{Definition: Definition[config]{Defaults: func() config { return config{Secret: SecretEnv("CONFIGKIT_RELOOKUP_SECRET")} }}}
realFirst, err := Load(NoSource(), options) realFirst, err := testLoad(NoSource(), options)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.Setenv("CONFIGKIT_RELOOKUP_SECRET", "two"); err != nil { if err := os.Setenv("CONFIGKIT_RELOOKUP_SECRET", "two"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
realSecond, err := Load(NoSource(), options) realSecond, err := testLoad(NoSource(), options)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -276,7 +276,7 @@ func TestSecretSafeFormattingJSONProjectionAndHistory(t *testing.T) {
type config struct { type config struct {
Secret Secret `yaml:"secret"` Secret Secret `yaml:"secret"`
} }
result, err := loadWithSecretLookup(Bytes("safe-secret.yml", []byte("secret: SAFE_SECRET_NAME\n")), func(string) (string, bool) { result, err := testLoadWithSecretLookup(Bytes("safe-secret.yml", []byte("secret: SAFE_SECRET_NAME\n")), func(string) (string, bool) {
return credential, true return credential, true
}, Options[config]{}) }, Options[config]{})
if err != nil { if err != nil {
@@ -317,7 +317,7 @@ func TestBootstrapExistingEmptyWinsAndReferenceFails(t *testing.T) {
type config struct { type config struct {
Secret Secret `yaml:"secret"` Secret Secret `yaml:"secret"`
} }
_, err = Load[config](Bytes("blocked.yml", []byte("secret: "+name+"\n"))) _, err = testLoad[config](Bytes("blocked.yml", []byte("secret: "+name+"\n")))
requireSecretLoadCode(t, err, CodeSecretEmpty) requireSecretLoadCode(t, err, CodeSecretEmpty)
if value, present := os.LookupEnv(name); !present || value != "" { if value, present := os.LookupEnv(name); !present || value != "" {
t.Fatalf("environment changed to %q, %v", value, present) t.Fatalf("environment changed to %q, %v", value, present)
@@ -339,7 +339,7 @@ func TestDynamicValuesDoNotEstablishSecretReferences(t *testing.T) {
} }
} }
lookups := 0 lookups := 0
result, err := loadWithSecretLookup(Bytes("dynamic.yml", []byte("dynamic: ORDINARY_STRING\n")), func(string) (string, bool) { result, err := testLoadWithSecretLookup(Bytes("dynamic.yml", []byte("dynamic: ORDINARY_STRING\n")), func(string) (string, bool) {
lookups++ lookups++
return "unexpected", true return "unexpected", true
}, Options[config]{}) }, Options[config]{})
@@ -354,9 +354,9 @@ func TestLoadDoesNotMutateSecretEnvironment(t *testing.T) {
type config struct { type config struct {
Secret Secret `yaml:"secret"` Secret Secret `yaml:"secret"`
} }
result, err := Load[config](Bytes("unchanged.yml", []byte("secret: "+name+"\n"))) result, err := testLoad[config](Bytes("unchanged.yml", []byte("secret: "+name+"\n")))
if err != nil || result.Config.Secret.Value() != "original" { if err != nil || result.Config.Secret.Value() != "original" {
t.Fatalf("Load() = %#v, %v", result, err) t.Fatalf("testLoad() = %#v, %v", result, err)
} }
if got := os.Getenv(name); got != "original" { if got := os.Getenv(name); got != "original" {
t.Fatalf("environment = %q", got) t.Fatalf("environment = %q", got)

View File

@@ -26,7 +26,7 @@ func TestNonregularSourcesDoNotBlock(t *testing.T) {
case "profile": case "profile":
opts.Profile = "fifo" opts.Profile = "fifo"
} }
_, err := Load(source, opts) _, err := testLoad(source, opts)
var loadErr *LoadError var loadErr *LoadError
if !errors.As(err, &loadErr) { if !errors.As(err, &loadErr) {
t.Fatalf("expected safe load error, got %v", err) t.Fatalf("expected safe load error, got %v", err)

View File

@@ -110,7 +110,7 @@ func runValidation[T any](configuration *T, validator func(*T, *Validation), sch
return canonicalDiagnostics(state.diagnostics) return canonicalDiagnostics(state.diagnostics)
} }
state.diagnostics = append(state.diagnostics, validationValueMutations(before, after)...) state.diagnostics = append(state.diagnostics, validationValueMutations(before, after)...)
for _, path := range changedSecretStates(reflect.ValueOf(*configuration), schema.root, lookups) { for _, path := range changedSecretStates(reflect.ValueOf(*configuration), schema, lookups) {
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(path)) state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(path))
} }
return canonicalDiagnostics(state.diagnostics) return canonicalDiagnostics(state.diagnostics)

View File

@@ -14,7 +14,7 @@ func TestValidationCollectsSchemaAwareReportsAndNearestLocations(t *testing.T) {
Missing string `yaml:"missing"` Missing string `yaml:"missing"`
Dynamic map[string]any `yaml:"dynamic"` Dynamic map[string]any `yaml:"dynamic"`
} }
result, err := Load(Bytes("validation.yml", []byte("primary: configured\ndynamic: {present: 1}\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad(Bytes("validation.yml", []byte("primary: configured\ndynamic: {present: 1}\n")), Options[config]{Definition: Definition[config]{
Validate: func(_ *config, validation *Validation) { Validate: func(_ *config, validation *Validation) {
validation.Error(Keys("missing"), "is required", Keys("primary")) validation.Error(Keys("missing"), "is required", Keys("primary"))
validation.Error(Keys("dynamic", "missing"), "dynamic entry is required") validation.Error(Keys("dynamic", "missing"), "dynamic entry is required")
@@ -58,7 +58,7 @@ func TestValidationRejectsUnknownStructPaths(t *testing.T) {
type config struct { type config struct {
Known string `yaml:"known"` Known string `yaml:"known"`
} }
result, err := Load(Bytes("validation-path.yml", []byte("known: value\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad(Bytes("validation-path.yml", []byte("known: value\n")), Options[config]{Definition: Definition[config]{
Validate: func(_ *config, validation *Validation) { Validate: func(_ *config, validation *Validation) {
validation.Error(Keys("unknown"), "must be configured") validation.Error(Keys("unknown"), "must be configured")
}, },
@@ -73,7 +73,7 @@ func TestValidationMutationFailsWithoutPublishingResult(t *testing.T) {
Value int `yaml:"value"` Value int `yaml:"value"`
Items map[string]int `yaml:"items"` Items map[string]int `yaml:"items"`
} }
result, err := Load(Bytes("mutation.yml", []byte("value: 1\nitems: {one: 1}\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad(Bytes("mutation.yml", []byte("value: 1\nitems: {one: 1}\n")), Options[config]{Definition: Definition[config]{
Validate: func(configuration *config, _ *Validation) { Validate: func(configuration *config, _ *Validation) {
configuration.Value = 2 configuration.Value = 2
configuration.Items["one"] = 2 configuration.Items["one"] = 2
@@ -127,7 +127,7 @@ func TestHookOrderStopsAfterErrorsAndContinuesAfterWarnings(t *testing.T) {
validation.Warning(Keys("value"), "validation warning") validation.Warning(Keys("value"), "validation warning")
}, },
} }
result, err := Load(Bytes("order.yml", []byte("version: one\n")), Options[config]{Definition: definition}) result, err := testLoad(Bytes("order.yml", []byte("version: one\n")), Options[config]{Definition: definition})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -148,7 +148,7 @@ func TestHookOrderStopsAfterErrorsAndContinuesAfterWarnings(t *testing.T) {
Resolve: func(_ *config, _ *Resolution) error { events = append(events, "resolve"); return nil }, Resolve: func(_ *config, _ *Resolution) error { events = append(events, "resolve"); return nil },
Validate: func(_ *config, _ *Validation) { events = append(events, "validate") }, Validate: func(_ *config, _ *Validation) { events = append(events, "validate") },
} }
result, err := Load(Bytes("order-error.yml", []byte("version: one\n")), Options[config]{Definition: definition}) result, err := testLoad(Bytes("order-error.yml", []byte("version: one\n")), Options[config]{Definition: definition})
assertLoadDiagnostic(t, result, err, CodeVersionError) assertLoadDiagnostic(t, result, err, CodeVersionError)
if !reflect.DeepEqual(events, []string{"version"}) { if !reflect.DeepEqual(events, []string{"version"}) {
t.Fatalf("events = %#v", events) t.Fatalf("events = %#v", events)
@@ -157,7 +157,7 @@ func TestHookOrderStopsAfterErrorsAndContinuesAfterWarnings(t *testing.T) {
t.Run("resolution error stops validation", func(t *testing.T) { t.Run("resolution error stops validation", func(t *testing.T) {
var validated bool var validated bool
result, err := Load(Bytes("resolution-order.yml", []byte("value: 1\nversion: one\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad(Bytes("resolution-order.yml", []byte("value: 1\nversion: one\n")), Options[config]{Definition: Definition[config]{
Resolve: func(_ *config, resolution *Resolution) error { Resolve: func(_ *config, resolution *Resolution) error {
resolution.Error(Keys("value"), "cannot resolve") resolution.Error(Keys("value"), "cannot resolve")
return nil return nil
@@ -172,7 +172,7 @@ func TestHookOrderStopsAfterErrorsAndContinuesAfterWarnings(t *testing.T) {
t.Run("decode error stops application hooks", func(t *testing.T) { t.Run("decode error stops application hooks", func(t *testing.T) {
var resolved, validated bool var resolved, validated bool
result, err := Load(Bytes("decode-order.yml", []byte("value: wrong\nversion: one\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad(Bytes("decode-order.yml", []byte("value: wrong\nversion: one\n")), Options[config]{Definition: Definition[config]{
Resolve: func(_ *config, _ *Resolution) error { resolved = true; return nil }, Resolve: func(_ *config, _ *Resolution) error { resolved = true; return nil },
Validate: func(_ *config, _ *Validation) { validated = true }, Validate: func(_ *config, _ *Validation) { validated = true },
}}) }})
@@ -190,7 +190,7 @@ func TestCompleteSingleSourceLifecycleKeepsMetadataAgreement(t *testing.T) {
Input int `yaml:"input"` Input int `yaml:"input"`
Derived int `yaml:"derived"` Derived int `yaml:"derived"`
} }
result, err := Load(Bytes("complete.yml", []byte("input: 3\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad(Bytes("complete.yml", []byte("input: 3\n")), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Derived: 1} }, Defaults: func() config { return config{Derived: 1} },
Resolve: func(configuration *config, resolution *Resolution) error { Resolve: func(configuration *config, resolution *Resolution) error {
Default(resolution, Keys("derived"), &configuration.Derived, configuration.Input*2, "derived from input", Keys("input")) Default(resolution, Keys("derived"), &configuration.Derived, configuration.Input*2, "derived from input", Keys("input"))

View File

@@ -34,10 +34,10 @@ type valueOperation struct {
} }
func (o *valueOperation) dynamicDescriptor(current reflect.Type, path Path) (*typeDescriptor, *projectionIssue) { func (o *valueOperation) dynamicDescriptor(current reflect.Type, path Path) (*typeDescriptor, *projectionIssue) {
if current == secretType { if o.schema.secrets.Type != nil && current == o.schema.secrets.Type {
return nil, &projectionIssue{code: CodeSchemaDynamicType, path: path.clone(), message: "dynamic configuration values cannot establish secret-reference semantics"} return nil, &projectionIssue{code: CodeSchemaDynamicType, path: path.clone(), message: "dynamic configuration values cannot establish secret-reference semantics"}
} }
builder := schemaBuilder{cache: o.schema.types, building: make(map[reflect.Type]bool)} builder := schemaBuilder{cache: o.schema.types, building: make(map[reflect.Type]bool), secrets: o.schema.secrets}
descriptor, issue := builder.describe(current, path) descriptor, issue := builder.describe(current, path)
if issue != nil { if issue != nil {
return nil, &projectionIssue{code: CodeSchemaDynamicType, path: path.clone(), message: "dynamic configuration value has an unsupported type"} return nil, &projectionIssue{code: CodeSchemaDynamicType, path: path.clone(), message: "dynamic configuration value has an unsupported type"}

View File

@@ -15,7 +15,7 @@ func TestVersionPathsShareCanonicalSequenceSemantics(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
var checked Version var checked Version
result, err := Load[config](Bytes("version.yml", []byte("versions: ["+value+"]\n")), Options[config]{Definition: Definition[config]{ result, err := testLoad[config](Bytes("version.yml", []byte("versions: ["+value+"]\n")), Options[config]{Definition: Definition[config]{
Version: &VersionRule{Path: path, Check: func(v Version, _ *Validation) error { checked = v; return nil }}, Version: &VersionRule{Path: path, Check: func(v Version, _ *Validation) error { checked = v; return nil }},
}}) }})
want := Version{} want := Version{}
@@ -49,7 +49,7 @@ func TestVersionPathsPreserveLiteralMappingKeysAndSegmentIntent(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
result, err := Load[map[string]string](Bytes("version.yml", []byte("'"+key+"': 4\n")), Options[map[string]string]{Definition: Definition[map[string]string]{ result, err := testLoad[map[string]string](Bytes("version.yml", []byte("'"+key+"': 4\n")), Options[map[string]string]{Definition: Definition[map[string]string]{
Version: &VersionRule{Path: path, Check: func(v Version, _ *Validation) error { Version: &VersionRule{Path: path, Check: func(v Version, _ *Validation) error {
if !v.Present || v.Text != "4" { if !v.Present || v.Text != "4" {
t.Errorf("checked = %#v", v) t.Errorf("checked = %#v", v)
@@ -77,7 +77,7 @@ func TestVersionPathsPreserveLiteralMappingKeysAndSegmentIntent(t *testing.T) {
type config struct { type config struct {
Versions any `yaml:"versions"` Versions any `yaml:"versions"`
} }
_, err := Load[config](Bytes("version.yml", []byte(test.input)), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: test.path, Check: func(v Version, validation *Validation) error { _, err := testLoad[config](Bytes("version.yml", []byte(test.input)), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: test.path, Check: func(v Version, validation *Validation) error {
if v.Present { if v.Present {
t.Errorf("wrong segment kind found version: %#v", v) t.Errorf("wrong segment kind found version: %#v", v)
} }