Compare commits
56 Commits
874adaaa18
...
v0.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
| e6ced0d3d1 | |||
| 73ce6e9e31 | |||
| ae91f57bbe | |||
| 66e080ed34 | |||
| d8d9dbfe7f | |||
| aead20fae4 | |||
| c5a2ddee7a | |||
| 418fefdeef | |||
| 21ae2159f7 | |||
| b9425c9ae2 | |||
| fbe7b61c79 | |||
| 34380f62a5 | |||
| f2c650b5eb | |||
| 5760614834 | |||
| 88e86de03e | |||
| c98435ce8e | |||
| 6102b36adf | |||
| 7395f54513 | |||
| 5dc7e18d83 | |||
| 7f46c947dd | |||
| 550f92d7c6 | |||
| da307c9f49 | |||
| b8935e77bd | |||
| 05812a6ad3 | |||
| da02a19f2c | |||
| 9b4f9ac50e | |||
| f81def9365 | |||
| eb7de8e40a | |||
| 28e7fc5f69 | |||
| 715364b079 | |||
| b9e4dfac92 | |||
| cc98be8f0a | |||
| e20a0a0998 | |||
| 88f21d09c7 | |||
| 818d36f836 | |||
| 381430a24b | |||
| 8a62b42503 | |||
| 244104f84f | |||
| fd8a68d268 | |||
| 8702121fa4 | |||
| 6eb40c5492 | |||
| f5bbc2be5d | |||
| b3e7b62119 | |||
| cd730d5c99 | |||
| 55fd42921a | |||
| 4bd83639a3 | |||
| 994a9ea0e2 | |||
| cb5b4356aa | |||
| 22a03c9f4d | |||
| 6a1c7df4be | |||
| 75a5829132 | |||
| 1cc645f30f | |||
| 633c5cd519 | |||
| fc2b6c81a1 | |||
| a04d32c389 | |||
| 49e04442da |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -25,6 +25,9 @@ go.work.sum
|
|||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Local codebase-memory tooling artifacts
|
||||||
|
/.codebase-memory/
|
||||||
|
|
||||||
# ---> Linux
|
# ---> Linux
|
||||||
*~
|
*~
|
||||||
|
|
||||||
@@ -81,4 +84,3 @@ Temporary Items
|
|||||||
.history/
|
.history/
|
||||||
|
|
||||||
# Built Visual Studio Code Extensions
|
# Built Visual Studio Code Extensions
|
||||||
*.vsix
|
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
Please review `docs/development.md` for initial orientation in this repository
|
Please review `docs/development.md` for initial orientation in this repository
|
||||||
and follow its task-specific reading guide.
|
and follow its task-specific reading guide.
|
||||||
|
|
||||||
|
Review and follow `docs/policy/agents.md` before recommending, planning, or
|
||||||
|
implementing any changes to this repository.
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -2,17 +2,20 @@
|
|||||||
|
|
||||||
An opinionated Go library for applications that use YAML configuration files.
|
An opinionated Go library for applications that use YAML configuration files.
|
||||||
|
|
||||||
|
Linux is release-validated; macOS support is best-effort. See the
|
||||||
|
[platform support policy](docs/policy/release.md#platform-support) for details.
|
||||||
|
|
||||||
ConfigKit's initial loader accepts one strict YAML
|
ConfigKit's initial loader accepts one strict YAML
|
||||||
source (or explicit defaults-only input), optionally traverses explicit path and glob
|
source (or explicit defaults-only input), optionally traverses explicit path and glob
|
||||||
imports, strictly composes their disjoint definitions, applies typed defaults, and
|
imports, strictly composes their disjoint definitions, applies typed defaults, and
|
||||||
can overlay one selected fixed profile and check an application schema version. It
|
can overlay one selected fixed overlay and check an application schema version. It
|
||||||
returns detached metadata and safe diagnostics.
|
returns detached metadata and safe diagnostics.
|
||||||
Applications may perform audited defaulting and normalization through a resolver,
|
Applications may perform audited defaulting and normalization through a resolver,
|
||||||
then report domain errors and warnings through read-only semantic validation. Secret
|
then report domain errors and warnings through read-only semantic validation. Secret
|
||||||
tags and a path predicate drive deterministic redacted effective YAML. Typed secret
|
tags and a path predicate drive deterministic redacted effective YAML. Typed secret
|
||||||
references resolve environment names before validation, and explicit
|
references resolve environment names before validation, and explicit
|
||||||
secrets-directory bootstrap is available as a separate startup operation. Explicit
|
secrets-directory bootstrap is available as a separate startup operation. Explicit
|
||||||
CLI override records apply above profiles. Value explanation is available through
|
CLI override records apply above overlays. Value explanation is available through
|
||||||
detached structured records and deterministic redacted output.
|
detached structured records and deterministic redacted output.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@@ -33,9 +36,7 @@ func ReadConfig(path string) (Config, error) {
|
|||||||
The [configuration contract](docs/config.md) describes implemented behavior. The
|
The [configuration contract](docs/config.md) describes implemented behavior. The
|
||||||
[consumer adoption guide](docs/consumers/README.md) provides maintained Narratio-,
|
[consumer adoption guide](docs/consumers/README.md) provides maintained Narratio-,
|
||||||
Notarius-, WeatherReporter-, and Seriatim-shaped examples and migration guidance.
|
Notarius-, WeatherReporter-, and Seriatim-shaped examples and migration guidance.
|
||||||
The [initial target record](docs/roadmap/initial.md) links implemented feature owners
|
The [deferred scope](docs/roadmap/deferred.md) records deliberate exclusions. See the
|
||||||
and preserves deliberately deferred scope. The [implementation plan](docs/roadmap/implementation.md)
|
|
||||||
records completion evidence until release-candidate preparation retires it. See the
|
|
||||||
[development guide](docs/development.md) for repository orientation and the
|
[development guide](docs/development.md) for repository orientation and the
|
||||||
[release procedure](docs/release.md) for maintainer-only candidate and publication
|
[release procedure](docs/release.md) for maintainer-only candidate and publication
|
||||||
steps.
|
steps.
|
||||||
|
|||||||
133
conversion.go
Normal file
133
conversion.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||||
|
|
||||||
|
func fromEngineLocation(value engine.Location) Location {
|
||||||
|
return Location{SourceID: value.SourceID, Line: value.Line, Column: value.Column}
|
||||||
|
}
|
||||||
|
func toEngineLocation(value Location) engine.Location {
|
||||||
|
return engine.Location{SourceID: value.SourceID, Line: value.Line, Column: value.Column}
|
||||||
|
}
|
||||||
|
func fromEngineLocationPointer(value *engine.Location) *Location {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := fromEngineLocation(*value)
|
||||||
|
return &result
|
||||||
|
}
|
||||||
|
func toEngineLocationPointer(value *Location) *engine.Location {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := toEngineLocation(*value)
|
||||||
|
return &result
|
||||||
|
}
|
||||||
|
func fromEngineLocations(values []engine.Location) []Location {
|
||||||
|
if values == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]Location, len(values))
|
||||||
|
for i := range values {
|
||||||
|
result[i] = fromEngineLocation(values[i])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
func toEngineLocations(values []Location) []engine.Location {
|
||||||
|
if values == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]engine.Location, len(values))
|
||||||
|
for i := range values {
|
||||||
|
result[i] = toEngineLocation(values[i])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
|
||||||
|
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: Layer(value.Layer), ImportedFrom: fromEngineLocationPointer(value.ImportedFrom)}
|
||||||
|
}
|
||||||
|
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
|
||||||
|
return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: engine.Layer(value.Layer), ImportedFrom: toEngineLocationPointer(value.ImportedFrom)}
|
||||||
|
}
|
||||||
|
func fromEngineOrigin(value engine.Origin) Origin {
|
||||||
|
return Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
|
||||||
|
}
|
||||||
|
func toEngineOrigin(value Origin) engine.Origin {
|
||||||
|
return engine.Origin{Layer: engine.Layer(value.Layer), Locations: toEngineLocations(value.Locations), Operation: engine.ChangeKind(value.Operation)}
|
||||||
|
}
|
||||||
|
func fromEngineDiagnostic(value engine.Diagnostic) Diagnostic {
|
||||||
|
return Diagnostic{Code: Code(value.Code), Severity: Severity(value.Severity), Path: fromEnginePath(value.Path), Message: value.Message, Primary: fromEngineLocationPointer(value.Primary), Related: fromEngineLocations(value.Related)}
|
||||||
|
}
|
||||||
|
func toEngineDiagnostic(value Diagnostic) engine.Diagnostic {
|
||||||
|
return engine.Diagnostic{Code: engine.Code(value.Code), Severity: engine.Severity(value.Severity), Path: toEnginePath(value.Path), Message: value.Message, Primary: toEngineLocationPointer(value.Primary), Related: toEngineLocations(value.Related)}
|
||||||
|
}
|
||||||
|
func toEngineDiagnostics(values []Diagnostic) []engine.Diagnostic {
|
||||||
|
if values == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]engine.Diagnostic, len(values))
|
||||||
|
for i := range values {
|
||||||
|
result[i] = toEngineDiagnostic(values[i])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
func fromEngineFieldInfo(value engine.FieldInfo) FieldInfo {
|
||||||
|
return FieldInfo{Explicit: value.Explicit, Null: value.Null, Origin: fromEngineOrigin(value.Origin)}
|
||||||
|
}
|
||||||
|
func fromEngineChange(value engine.Change) Change {
|
||||||
|
result := Change{Path: fromEnginePath(value.Path), Origin: fromEngineOrigin(value.Origin), Reasons: append([]string(nil), value.Reasons...)}
|
||||||
|
if value.Previous != nil {
|
||||||
|
result.Previous = make([]Origin, len(value.Previous))
|
||||||
|
for i := range value.Previous {
|
||||||
|
result.Previous[i] = fromEngineOrigin(value.Previous[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value.Dependencies != nil {
|
||||||
|
result.Dependencies = make([]Path, len(value.Dependencies))
|
||||||
|
for i := range value.Dependencies {
|
||||||
|
result.Dependencies[i] = fromEnginePath(value.Dependencies[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromEngineValueSnapshot(value engine.ValueSnapshot) ValueSnapshot {
|
||||||
|
return ValueSnapshot{Present: value.Present, Redacted: value.Redacted, YAML: value.YAML}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromEngineValueChange(value engine.ValueChange) ValueChange {
|
||||||
|
result := ValueChange{
|
||||||
|
Before: fromEngineValueSnapshot(value.Before),
|
||||||
|
After: fromEngineValueSnapshot(value.After),
|
||||||
|
Origin: fromEngineOrigin(value.Origin),
|
||||||
|
Reasons: append([]string(nil), value.Reasons...),
|
||||||
|
}
|
||||||
|
if value.Dependencies != nil {
|
||||||
|
result.Dependencies = make([]Path, len(value.Dependencies))
|
||||||
|
for index := range value.Dependencies {
|
||||||
|
result.Dependencies[index] = fromEnginePath(value.Dependencies[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromEngineExplanation(value engine.Explanation) Explanation {
|
||||||
|
result := Explanation{
|
||||||
|
Path: fromEnginePath(value.Path),
|
||||||
|
Effective: fromEngineValueSnapshot(value.Effective),
|
||||||
|
Origin: fromEngineOrigin(value.Origin),
|
||||||
|
}
|
||||||
|
if value.History != nil {
|
||||||
|
result.History = make([]ValueChange, len(value.History))
|
||||||
|
for index := range value.History {
|
||||||
|
result.History[index] = fromEngineValueChange(value.History[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value.SecretReference != nil {
|
||||||
|
result.SecretReference = &SecretReferenceInfo{Name: value.SecretReference.Name}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromEngineSecretFileResult(value engine.SecretFileResult) SecretFileResult {
|
||||||
|
return SecretFileResult{Name: value.Name, Path: value.Path, Status: SecretFileStatus(value.Status), ExistingEmpty: value.ExistingEmpty}
|
||||||
|
}
|
||||||
234
diagnostic.go
234
diagnostic.go
@@ -1,239 +1,57 @@
|
|||||||
package configkit
|
package configkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"slices"
|
|
||||||
"strconv"
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// LoadError reports safe structured diagnostics for a failed configuration load.
|
// LoadError reports safe structured diagnostics for a failed configuration load.
|
||||||
// It intentionally does not expose or unwrap parser and application errors.
|
// It intentionally does not expose or unwrap parser and application errors.
|
||||||
type LoadError struct {
|
type LoadError struct{ value *engine.LoadError }
|
||||||
diagnostics []Diagnostic
|
|
||||||
sources []SourceInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error implements error without rendering diagnostic messages or input values.
|
// Error implements error without rendering diagnostic messages or input values.
|
||||||
func (e *LoadError) Error() string {
|
func (e *LoadError) Error() string {
|
||||||
if e == nil {
|
if e == nil || e.value == nil {
|
||||||
return "configkit: configuration load failed"
|
return "configkit: configuration load failed"
|
||||||
}
|
}
|
||||||
count := 0
|
return e.value.Error()
|
||||||
for _, diagnostic := range e.diagnostics {
|
|
||||||
if diagnostic.Severity == SeverityError {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if count == 1 {
|
|
||||||
return "configkit: configuration load failed with 1 error"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("configkit: configuration load failed with %d errors", count)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diagnostics returns a deep copy of the error's canonical diagnostics.
|
// Diagnostics returns a deep copy of the error's canonical diagnostics.
|
||||||
func (e *LoadError) Diagnostics() []Diagnostic {
|
func (e *LoadError) Diagnostics() []Diagnostic {
|
||||||
if e == nil {
|
if e == nil || e.value == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return cloneDiagnostics(e.diagnostics)
|
values := e.value.Diagnostics()
|
||||||
|
if values == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]Diagnostic, len(values))
|
||||||
|
for i := range values {
|
||||||
|
result[i] = fromEngineDiagnostic(values[i])
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sources returns a deep copy of sources discovered before loading failed.
|
// Sources returns a deep copy of sources discovered before loading failed.
|
||||||
func (e *LoadError) Sources() []SourceInfo {
|
func (e *LoadError) Sources() []SourceInfo {
|
||||||
if e == nil {
|
if e == nil || e.value == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return cloneSourceInfos(e.sources)
|
values := e.value.Sources()
|
||||||
|
if values == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]SourceInfo, len(values))
|
||||||
|
for i := range values {
|
||||||
|
result[i] = fromEngineSourceInfo(values[i])
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteDiagnostics writes diagnostics in canonical order. It escapes terminal
|
// WriteDiagnostics writes diagnostics in canonical order. It escapes terminal
|
||||||
// control characters and propagates writer errors, including short writes.
|
// control characters and propagates writer errors, including short writes.
|
||||||
func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
|
func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
|
||||||
canonical := canonicalDiagnostics(diagnostics)
|
return engine.WriteDiagnostics(output, toEngineDiagnostics(diagnostics))
|
||||||
for _, diagnostic := range canonical {
|
|
||||||
if err := writeDiagnostic(output, diagnostic); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type diagnosticAccumulator struct {
|
|
||||||
diagnostics []Diagnostic
|
|
||||||
sources []SourceInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *diagnosticAccumulator) add(diagnostics ...Diagnostic) {
|
|
||||||
for _, diagnostic := range diagnostics {
|
|
||||||
a.diagnostics = append(a.diagnostics, cloneDiagnostic(diagnostic))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *diagnosticAccumulator) loadError() *LoadError {
|
|
||||||
return &LoadError{
|
|
||||||
diagnostics: canonicalDiagnostics(a.diagnostics),
|
|
||||||
sources: cloneSourceInfos(a.sources),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func canonicalDiagnostics(diagnostics []Diagnostic) []Diagnostic {
|
|
||||||
canonical := cloneDiagnostics(diagnostics)
|
|
||||||
for i := range canonical {
|
|
||||||
slices.SortFunc(canonical[i].Related, compareLocation)
|
|
||||||
}
|
|
||||||
slices.SortFunc(canonical, compareDiagnostic)
|
|
||||||
return canonical
|
|
||||||
}
|
|
||||||
|
|
||||||
func compareDiagnostic(left, right Diagnostic) int {
|
|
||||||
if order := compareOptionalLocation(left.Primary, right.Primary); order != 0 {
|
|
||||||
return order
|
|
||||||
}
|
|
||||||
if order := cmp.Compare(left.Path.String(), right.Path.String()); order != 0 {
|
|
||||||
return order
|
|
||||||
}
|
|
||||||
if order := cmp.Compare(left.Code, right.Code); order != 0 {
|
|
||||||
return order
|
|
||||||
}
|
|
||||||
if order := cmp.Compare(left.Severity, right.Severity); order != 0 {
|
|
||||||
return order
|
|
||||||
}
|
|
||||||
if order := cmp.Compare(left.Message, right.Message); order != 0 {
|
|
||||||
return order
|
|
||||||
}
|
|
||||||
return compareLocations(left.Related, right.Related)
|
|
||||||
}
|
|
||||||
|
|
||||||
func compareOptionalLocation(left, right *Location) int {
|
|
||||||
if left == nil && right == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if left == nil {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
if right == nil {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return compareLocation(*left, *right)
|
|
||||||
}
|
|
||||||
|
|
||||||
func compareLocations(left, right []Location) int {
|
|
||||||
for i := 0; i < min(len(left), len(right)); i++ {
|
|
||||||
if order := compareLocation(left[i], right[i]); order != 0 {
|
|
||||||
return order
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cmp.Compare(len(left), len(right))
|
|
||||||
}
|
|
||||||
|
|
||||||
func compareLocation(left, right Location) int {
|
|
||||||
if order := compareSourceID(left.SourceID, right.SourceID); order != 0 {
|
|
||||||
return order
|
|
||||||
}
|
|
||||||
if order := cmp.Compare(left.Line, right.Line); order != 0 {
|
|
||||||
return order
|
|
||||||
}
|
|
||||||
return cmp.Compare(left.Column, right.Column)
|
|
||||||
}
|
|
||||||
|
|
||||||
func compareSourceID(left, right string) int {
|
|
||||||
leftOrder, leftOK := sourceSequence(left)
|
|
||||||
rightOrder, rightOK := sourceSequence(right)
|
|
||||||
if leftOK && rightOK {
|
|
||||||
return cmp.Compare(leftOrder, rightOrder)
|
|
||||||
}
|
|
||||||
return cmp.Compare(left, right)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sourceSequence(sourceID string) (int, bool) {
|
|
||||||
const prefix = "source-"
|
|
||||||
if !strings.HasPrefix(sourceID, prefix) {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
value, err := strconv.Atoi(strings.TrimPrefix(sourceID, prefix))
|
|
||||||
if err != nil || value < 1 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return value, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeDiagnostic(output io.Writer, diagnostic Diagnostic) error {
|
|
||||||
severity := diagnostic.Severity
|
|
||||||
if severity == "" {
|
|
||||||
severity = SeverityError
|
|
||||||
}
|
|
||||||
path := diagnostic.Path.String()
|
|
||||||
if path == "" {
|
|
||||||
path = "<root>"
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString(escapeControls(string(severity)))
|
|
||||||
if diagnostic.Code != "" {
|
|
||||||
b.WriteString(" [")
|
|
||||||
b.WriteString(escapeControls(string(diagnostic.Code)))
|
|
||||||
b.WriteByte(']')
|
|
||||||
}
|
|
||||||
b.WriteByte(' ')
|
|
||||||
b.WriteString(escapeControls(path))
|
|
||||||
if diagnostic.Primary != nil {
|
|
||||||
b.WriteString(" (")
|
|
||||||
writeLocationText(&b, *diagnostic.Primary)
|
|
||||||
b.WriteByte(')')
|
|
||||||
}
|
|
||||||
if diagnostic.Message != "" {
|
|
||||||
b.WriteString(": ")
|
|
||||||
b.WriteString(escapeControls(diagnostic.Message))
|
|
||||||
}
|
|
||||||
b.WriteByte('\n')
|
|
||||||
for _, related := range diagnostic.Related {
|
|
||||||
b.WriteString(" related: ")
|
|
||||||
writeLocationText(&b, related)
|
|
||||||
b.WriteByte('\n')
|
|
||||||
}
|
|
||||||
return writeString(output, b.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeLocationText(output *strings.Builder, location Location) {
|
|
||||||
label := location.SourceID
|
|
||||||
if label == "" {
|
|
||||||
label = "<synthetic>"
|
|
||||||
}
|
|
||||||
output.WriteString(escapeControls(label))
|
|
||||||
if location.Line > 0 {
|
|
||||||
output.WriteByte(':')
|
|
||||||
output.WriteString(strconv.Itoa(location.Line))
|
|
||||||
if location.Column > 0 {
|
|
||||||
output.WriteByte(':')
|
|
||||||
output.WriteString(strconv.Itoa(location.Column))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeString(output io.Writer, value string) error {
|
|
||||||
written, err := io.WriteString(output, value)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if written != len(value) {
|
|
||||||
return io.ErrShortWrite
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func escapeControls(value string) string {
|
|
||||||
var b strings.Builder
|
|
||||||
for _, r := range value {
|
|
||||||
if unicode.IsControl(r) {
|
|
||||||
quoted := strconv.QuoteRune(r)
|
|
||||||
b.WriteString(quoted[1 : len(quoted)-1])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
b.WriteRune(r)
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
}
|
||||||
|
|||||||
11
doc.go
11
doc.go
@@ -1,10 +1,9 @@
|
|||||||
// Package configkit provides deterministic, application-owned configuration
|
// Package configkit provides deterministic, application-owned configuration
|
||||||
// loading for Go programs.
|
// loading for Go programs.
|
||||||
//
|
//
|
||||||
// Load currently supports one file, byte, reader, or explicit no-input source with
|
// Load supports file, byte, reader, and explicit no-input sources with strict YAML,
|
||||||
// strict YAML, typed defaults, schema-version checks, detached metadata, safe
|
// typed defaults, imports, overlays, CLI overrides, schema-version checks, detached
|
||||||
// diagnostics, recorded application resolution, read-only semantic validation, and
|
// metadata, recorded application resolution, typed secret references, safe effective
|
||||||
// structured load errors. Composition, profiles, overrides, secrets, and effective
|
// rendering, read-only semantic validation, and structured load errors. The
|
||||||
// rendering are not available yet. The configuration contract is documented in
|
// configuration contract is documented in docs/config.md.
|
||||||
// docs/config.md.
|
|
||||||
package configkit
|
package configkit
|
||||||
|
|||||||
61
docs/adr/0006-public-facade.md
Normal file
61
docs/adr/0006-public-facade.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# ADR 0006: Keep the public facade root-owned
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted and implemented on 2026-09-12.
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-09-12
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The root `configkit` package formerly combined the supported consumer API with
|
||||||
|
loading, decoding, inspection, and rendering machinery. That made ordinary
|
||||||
|
consumer navigation harder and risked exposing implementation details through
|
||||||
|
future public declarations. Moving files alone would not preserve the identity,
|
||||||
|
method sets, callbacks, ownership rules, or reflection-sensitive behavior of the
|
||||||
|
root's public types.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Keep `configkit` as the sole supported consumer import path and keep all public
|
||||||
|
named types defined there. Lifecycle machinery lives in a cohesive
|
||||||
|
`internal/engine`; the root facade holds private engine state and forwards
|
||||||
|
deliberately selected methods. The dependency direction is root to engine and
|
||||||
|
its supporting internal packages. Internal production packages do not import the
|
||||||
|
root package.
|
||||||
|
|
||||||
|
The facade will use root-owned wrappers and explicit private conversions. It
|
||||||
|
will not use public aliases to engine types, anonymous embedding that promotes
|
||||||
|
engine methods, public engine types in signatures or fields, or public bridge
|
||||||
|
methods. Existing document and YAML boundaries remain intact.
|
||||||
|
|
||||||
|
The migration used a temporary internal candidate facade while the unchanged root
|
||||||
|
remained the only production entry point. That duplication was a bounded
|
||||||
|
relocation mechanism, not a second supported package, feature path, or runtime
|
||||||
|
selection mechanism; the final cutover removed it and the old root implementation.
|
||||||
|
|
||||||
|
The [architecture policy](../policy/architecture.md#public-api-and-dependency-direction)
|
||||||
|
owns the binding boundary. The [component overview](../internal/overview.md)
|
||||||
|
describes the implemented layout; the [development guide](../development.md)
|
||||||
|
locates the completed roadmap and implementation history.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- Alias root types to engine types: reduces forwarding, but makes the engine's
|
||||||
|
exported method set and type ownership part of the consumer API.
|
||||||
|
- Keep all machinery in the root package: avoids migration work, but leaves the
|
||||||
|
public boundary difficult to navigate and easy to expand accidentally.
|
||||||
|
- Introduce a generic adapter or registration framework: could reduce some
|
||||||
|
handwritten conversions, but adds machinery and obscures the ownership rules
|
||||||
|
this boundary needs to preserve.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
The refactor carries explicit forwarding and conversion work, especially for
|
||||||
|
callbacks, detached records, source ownership, frozen inspection, and the
|
||||||
|
exact-type secret bridge. Public compatibility must be verified by behavior and
|
||||||
|
external-package evidence rather than inferred from package names. Internal
|
||||||
|
exports remain implementation surfaces, and the completed architecture will
|
||||||
|
have one engine and one root facade.
|
||||||
155
docs/config.md
155
docs/config.md
@@ -20,7 +20,7 @@ The zero `Source` is invalid. Constructor argument failures are retained inside
|
|||||||
opaque source and become safe structured diagnostics when the source is consumed.
|
opaque source and become safe structured diagnostics when the source is consumed.
|
||||||
File sources can observe file changes between consumptions; byte sources cannot.
|
File sources can observe file changes between consumptions; byte sources cannot.
|
||||||
|
|
||||||
Each source is limited to 8 MiB. A load using imports or an active profile is limited
|
Each source is limited to 8 MiB. A load using imports or an active overlay is limited
|
||||||
to 64 MiB across all accepted sources, 1,024 accepted sources, 64 active import
|
to 64 MiB across all accepted sources, 1,024 accepted sources, 64 active import
|
||||||
edges, and 1,000,000 input nodes in aggregate. Glob expansion separately permits
|
edges, and 1,000,000 input nodes in aggregate. Glob expansion separately permits
|
||||||
1,000,000 visited directory entries, including nonmatches. Reads request at most the
|
1,000,000 visited directory entries, including nonmatches. Reads request at most the
|
||||||
@@ -33,7 +33,7 @@ truncated. Data returned together with a read error is not accepted as partial i
|
|||||||
root `T` must satisfy the application schema contract below. With no options it
|
root `T` must satisfy the application schema contract below. With no options it
|
||||||
starts from the Go zero value. A `Definition[T].Defaults` factory, when supplied,
|
starts from the Go zero value. A `Definition[T].Defaults` factory, when supplied,
|
||||||
runs exactly once after successful source parsing, schema construction, optional
|
runs exactly once after successful source parsing, schema construction, optional
|
||||||
schema-version checking, and selected-profile loading. Its result is detached before
|
schema-version checking, and selected-overlay loading. Its result is detached before
|
||||||
input is applied. Resolver-provided mutable values are detached before secret lookup
|
input is applied. Resolver-provided mutable values are detached before secret lookup
|
||||||
and validation, and a final detached copy prevents equal-valued aliases introduced
|
and validation, and a final detached copy prevents equal-valued aliases introduced
|
||||||
by validators from escaping into the returned configuration. The publication copy
|
by validators from escaping into the returned configuration. The publication copy
|
||||||
@@ -43,7 +43,7 @@ The current lifecycle is:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
validate options → parse the selected source → traverse/compose enabled imports
|
validate options → parse the selected source → traverse/compose enabled imports
|
||||||
→ build schema → check the optional application version → apply a selected profile
|
→ build schema → check the optional application version → apply a selected overlay
|
||||||
→ apply explicit CLI overrides → create and clone defaults → strictly decode
|
→ apply explicit CLI overrides → create and clone defaults → strictly decode
|
||||||
→ recorded application resolution
|
→ recorded application resolution
|
||||||
→ resolve typed secret references → semantic validation → classify secrets
|
→ resolve typed secret references → semantic validation → classify secrets
|
||||||
@@ -57,11 +57,11 @@ contributes no source, and continues through defaults, decode, and projection. A
|
|||||||
missing file remains an error; it is never interpreted as no input.
|
missing file remains an error; it is never interpreted as no input.
|
||||||
|
|
||||||
The current `Options` boundary accepts a defaults factory, `VersionRule`, resolver,
|
The current `Options` boundary accepts a defaults factory, `VersionRule`, resolver,
|
||||||
semantic validator, explicit path and glob imports, one selected profile, and
|
semantic validator, explicit path and glob imports, one selected overlay, and
|
||||||
filesystem context. It also accepts explicit CLI override records, static secret
|
filesystem context. It also accepts explicit CLI override records, static secret
|
||||||
tags, and a dynamic path predicate. `NoSource` rejects imports, active profiles,
|
tags, and a dynamic path predicate. `NoSource` rejects imports, active overlays,
|
||||||
and filesystem context as invalid combinations but accepts overrides. Input
|
and filesystem context as invalid combinations but accepts overrides. Input
|
||||||
containing a reserved `_configkit` mapping key is rejected while imports are
|
containing a reserved `configkit` mapping key is rejected while imports are
|
||||||
disabled.
|
disabled.
|
||||||
|
|
||||||
Every source, options, schema, version, defaults, decode, resolution, or validation
|
Every source, options, schema, version, defaults, decode, resolution, or validation
|
||||||
@@ -74,9 +74,27 @@ frozen redacted projection.
|
|||||||
### Application schema versions
|
### Application schema versions
|
||||||
|
|
||||||
`VersionRule` declares a semantic path and non-nil checker. Before defaults or typed
|
`VersionRule` declares a semantic path and non-nil checker. Before defaults or typed
|
||||||
decoding, the checker receives a `Version` preserving absent, explicit null, or
|
decoding, the checker receives a `Version` preserving absent, explicit null, string,
|
||||||
string input. Any other input kind is a `version.type` error and the checker does not
|
or strict decimal-integer input. Strings and integers supply their exact spelling in
|
||||||
run. ConfigKit does not interpret the text or impose a universal version path.
|
`Version.Text`; ConfigKit does not impose a machine-integer range. Fractional or
|
||||||
|
exponent numbers, booleans, mappings, and sequences produce `version.type`, and the
|
||||||
|
checker does not run. ConfigKit does not interpret the text or impose a universal
|
||||||
|
version path.
|
||||||
|
|
||||||
|
Integer versions use the strict grammar `^-?(0|[1-9][0-9]*)$`. Their spelling is
|
||||||
|
preserved exactly, including `-0`, and application policy decides which identifiers
|
||||||
|
are supported. A numeric version has no built-in `int64` range limit. Existing parser
|
||||||
|
policy still rejects leading zeroes, leading plus signs, hexadecimal notation, and
|
||||||
|
other non-JSON scalar forms before version checking.
|
||||||
|
|
||||||
|
At exactly the configured semantic path, accepted integer input can decode into a
|
||||||
|
plain Go string or named string without a text codec, including pointer wrappers.
|
||||||
|
This is a load-local exception: siblings and unrelated fields, dynamic values,
|
||||||
|
integer and float destinations, durations, `Secret`, and text codecs retain ordinary
|
||||||
|
strict decoding. The retained source and history stay numeric while typed projection
|
||||||
|
and effective YAML contain the resulting string; explanation records this through
|
||||||
|
the normal decoding reconciliation event. Fragment, overlay, and CLI ownership
|
||||||
|
protections apply before this conversion.
|
||||||
|
|
||||||
The checker reports safe, value-free errors and warnings through `Validation.Error`
|
The checker reports safe, value-free errors and warnings through `Validation.Error`
|
||||||
and `Validation.Warning`; ConfigKit resolves primary and related paths to exact or
|
and `Validation.Warning`; ConfigKit resolves primary and related paths to exact or
|
||||||
@@ -91,30 +109,37 @@ successful result. Callback panics remain programming failures.
|
|||||||
key/index distinctions using actual effective and historical container shapes;
|
key/index distinctions using actual effective and historical container shapes;
|
||||||
only parsed numeric tokens are resolved contextually. A descendant that never
|
only parsed numeric tokens are resolved contextually. A descendant that never
|
||||||
existed is not historical merely because an atomic ancestor was replaced. Exact surviving input paths
|
existed is not historical merely because an atomic ancestor was replaced. Exact surviving input paths
|
||||||
are explicit and have a base or profile origin; omitted paths have a default origin.
|
are explicit and have a base or overlay origin; omitted paths have a default origin.
|
||||||
An explicit parent mapping does not make omitted children explicit. Null state and
|
An explicit parent mapping does not make omitted children explicit. Null state and
|
||||||
available source coordinates are retained. `History` records the Go-zero or application
|
available source coordinates are retained. `History` records the Go-zero or application
|
||||||
default baseline followed by exact base or profile definitions, including
|
default baseline followed by exact base or overlay definitions, including
|
||||||
equal-valued input. Profile replacement removes displaced descendants from effective
|
equal-valued input. Overlay replacement removes displaced descendants from effective
|
||||||
metadata while retaining their base definition and the parent replacement operation
|
metadata while retaining their base definition and the parent replacement operation
|
||||||
in history. All metadata queries and returned nested records are detached.
|
in history. All metadata queries and returned nested records are detached.
|
||||||
|
|
||||||
|
`Metadata.HistoricalPaths` enumerates observed paths, including removed paths,
|
||||||
|
without reloading input or resolving unselected credentials. Consumers can use
|
||||||
|
it with `History` to validate ownership of declarations hidden by an overlay;
|
||||||
|
domain ownership rules remain application responsibilities. The method's Go
|
||||||
|
documentation in [metadata.go](../metadata.go) defines enumeration and path-ordering
|
||||||
|
details.
|
||||||
|
|
||||||
History is chronological: a path can have a synthetic default record, its exact base
|
History is chronological: a path can have a synthetic default record, its exact base
|
||||||
definition, a profile replacement, and a recorded resolution operation. A descendant
|
definition, an overlay replacement, and a recorded resolution operation. A descendant
|
||||||
query uses the ancestor's path for a whole-container or whole-sequence replacement;
|
query uses the ancestor's path for a whole-container or whole-sequence replacement;
|
||||||
the descendant can therefore retain history after `Lookup` stops reporting it as
|
the descendant can therefore retain history after `Lookup` stops reporting it as
|
||||||
effective. Equal-valued input is still an explicit base definition, while a derived
|
effective. Equal-valued input is still an explicit base definition, while a derived
|
||||||
value for absent input remains non-explicit.
|
value for absent input remains non-explicit.
|
||||||
|
|
||||||
A mapping assembled from the selected file and imports has one composite base origin
|
A mapping assembled from the selected file and imports has one composite base origin
|
||||||
whose locations retain source traversal order. A mapping also visited by a profile
|
whose locations retain source traversal order. A mapping also visited by an overlay
|
||||||
has a profile-layer composite origin with the lower contributing locations followed
|
has an overlay-layer composite origin with the lower contributing locations followed
|
||||||
by the profile location; an empty mapping is still an explicit container even though
|
by the overlay location; an empty mapping is still an explicit container even though
|
||||||
it contributes no children. Resolution origins are synthetic: they have layer
|
it contributes no children. Resolution origins are synthetic: they have layer
|
||||||
`resolution`, no fabricated file location, and a `derived_default` or `normalization`
|
`resolution`, no fabricated file location, and a `derived_default` or `normalization`
|
||||||
operation. Reasons and dependency paths retain first occurrence order with duplicates
|
operation. Reasons and dependency paths retain first occurrence order with duplicates
|
||||||
removed. Source records remain ordered as selected source, depth-first imports, the
|
removed. Source records remain ordered as selected source, depth-first imports, the
|
||||||
selected profile, then synthetic CLI records in supplied order.
|
selected overlay, then synthetic CLI records in supplied order.
|
||||||
|
|
||||||
### Secret classification and effective rendering
|
### Secret classification and effective rendering
|
||||||
|
|
||||||
@@ -164,7 +189,7 @@ historical path. Its effective value and each chronological before/after value a
|
|||||||
parser values. `Present=false` with empty YAML means absence or deletion; explicit
|
parser values. `Present=false` with empty YAML means absence or deletion; explicit
|
||||||
null is present and renders as `null`. The history starts with the canonical typed
|
null is present and renders as `null`. The history starts with the canonical typed
|
||||||
application-default or Go-zero baseline, then includes only relevant base sources in
|
application-default or Go-zero baseline, then includes only relevant base sources in
|
||||||
traversal order, the selected profile, supplied CLI records, and audited net
|
traversal order, the selected overlay, supplied CLI records, and audited net
|
||||||
resolution operations. Overlapping parent/child resolution records are rendered
|
resolution operations. Overlapping parent/child resolution records are rendered
|
||||||
as one net subtree event with combined reasons and dependencies, rather than
|
as one net subtree event with combined reasons and dependencies, rather than
|
||||||
pretending that the recorded assignments ran in lexical order. Explicit equal
|
pretending that the recorded assignments ran in lexical order. Explicit equal
|
||||||
@@ -174,7 +199,7 @@ before domain resolution. Its origin retains the decoded path's source/default
|
|||||||
attribution; its reason identifies static-default retention and canonical decoding.
|
attribution; its reason identifies static-default retention and canonical decoding.
|
||||||
This built-in inspection operation is not accepted by `Resolution.Record`.
|
This built-in inspection operation is not accepted by `Resolution.Record`.
|
||||||
|
|
||||||
Base, profile, and CLI history preserves the policy-validated input representation,
|
Base, overlay, and CLI history preserves the policy-validated input representation,
|
||||||
including a superseded value that could not have decoded into the final Go field.
|
including a superseded value that could not have decoded into the final Go field.
|
||||||
Baseline, effective, and resolution values use canonical typed projection. A
|
Baseline, effective, and resolution values use canonical typed projection. A
|
||||||
decoding event bridges replayed input and canonical typed values, so intermediate
|
decoding event bridges replayed input and canonical typed values, so intermediate
|
||||||
@@ -268,10 +293,13 @@ operation once before starting concurrent work. The selected path may itself be
|
|||||||
symlink; ConfigKit resolves it to an existing canonical directory before examining
|
symlink; ConfigKit resolves it to an existing canonical directory before examining
|
||||||
its immediate entries.
|
its immediate entries.
|
||||||
|
|
||||||
The bootstrap enumerates at most 1,024 entries and sorts them by filename. Every
|
The bootstrap enumerates at most 1,024 entries, including dot-prefixed entries.
|
||||||
entry must have an ASCII environment-variable name and be a regular, nonsymlink
|
Entries whose names begin with `.` (files, directories, or symlinks, such as `.git`
|
||||||
file. Metadata for all entries is validated before file content is read or process
|
and `.gitignore`) are ignored before name or metadata validation, are never opened,
|
||||||
state is changed. Existing environment variables win, including variables whose
|
and are omitted from the report. Remaining entries are sorted by filename; each
|
||||||
|
must have an ASCII environment-variable name and be a regular, nonsymlink
|
||||||
|
file. Metadata for all remaining entries is validated before file content is read
|
||||||
|
or process state is changed. Existing environment variables win, including variables whose
|
||||||
value is empty; their files are not opened. Each eligible file is limited to 1 MiB,
|
value is empty; their files are not opened. Each eligible file is limited to 1 MiB,
|
||||||
and eligible content is limited to 8 MiB in total. ConfigKit removes exactly one
|
and eligible content is limited to 8 MiB in total. ConfigKit removes exactly one
|
||||||
terminal LF or CRLF, preserves all other whitespace, accepts an empty value, and
|
terminal LF or CRLF, preserves all other whitespace, accepts an empty value, and
|
||||||
@@ -301,6 +329,27 @@ receives the working configuration and a per-load `Resolution` context. The cont
|
|||||||
`IsExplicit` query uses surviving input presence, so explicit false, zero, empty, and
|
`IsExplicit` query uses surviving input presence, so explicit false, zero, empty, and
|
||||||
null are never confused with absence or static defaults.
|
null are never confused with absence or static defaults.
|
||||||
|
|
||||||
|
`Resolution.InputInfo(path)` returns detached effective metadata from the fixed
|
||||||
|
decoded entry state, before application resolution runs. It includes retained static
|
||||||
|
defaults, preserves explicit null, and uses the same exact semantic path matching as
|
||||||
|
`Metadata.Lookup`; missing, malformed, and key/index-shape-mismatched paths return
|
||||||
|
false without diagnostics. Its `Explicit` value agrees with `IsExplicit` for every
|
||||||
|
found entry path. Resolution changes, additions, removals, and moves do not alter
|
||||||
|
this entry view.
|
||||||
|
|
||||||
|
`Resolution.Source(id)` returns a detached record for an already acquired source in
|
||||||
|
the same load: the main source, imports, selected overlay, and supplied CLI records
|
||||||
|
where applicable. Empty and unknown IDs return false. It never reloads input, reads
|
||||||
|
the filesystem, resolves symlinks, or looks up credentials. A source path exists
|
||||||
|
only when the acquired source has one; defaults and byte, reader, and CLI sources do
|
||||||
|
not acquire an invented file path. Applications select a base directory from exact
|
||||||
|
origin locations and these source records according to their own policy.
|
||||||
|
|
||||||
|
Both queries are callback-scoped like the rest of `Resolution`: nil, zero, or
|
||||||
|
retained contexts panic with the fixed closed-context failure outside the callback.
|
||||||
|
Returned field and source records are detached and remain usable after callback
|
||||||
|
exit; changing them cannot affect later queries, results, or another load.
|
||||||
|
|
||||||
`Default` assigns and records a derived value only when its path is not explicit.
|
`Default` assigns and records a derived value only when its path is not explicit.
|
||||||
`Normalize` always assigns and records. For transformations performed directly by
|
`Normalize` always assigns and records. For transformations performed directly by
|
||||||
application code, `Resolution.Record` declares a derived-default or normalization
|
application code, `Resolution.Record` declares a derived-default or normalization
|
||||||
@@ -387,41 +436,41 @@ traversal order. Traversal order never authorizes replacement. Composition clone
|
|||||||
the resulting tree and its ordered claim records rather than mutating or aliasing an
|
the resulting tree and its ordered claim records rather than mutating or aliasing an
|
||||||
input document.
|
input document.
|
||||||
|
|
||||||
## Selected profiles
|
## Selected overlays
|
||||||
|
|
||||||
`Options.Profile` selects at most one overlay by name. Names must match
|
`Options.Overlay` selects at most one overlay by name. Names must match
|
||||||
`[a-zA-Z0-9_-]+`; they are not paths. The empty name and exact lowercase name
|
`[a-zA-Z0-9_-]+`; they are not paths. The empty name and exact lowercase name
|
||||||
`default` select no profile, and `profiles/default.yml` is never loaded implicitly.
|
`default` select no overlay, and `overlays/default.yml` is never loaded implicitly.
|
||||||
Every other name resolves only to `<configuration-root>/profiles/<name>.yml`, with
|
Every other name resolves only to `<configuration-root>/overlays/<name>.yml`, with
|
||||||
no extension fallback, case normalization, inheritance, or profile stack. A selected
|
no extension fallback, case normalization, inheritance, or overlay stack. A selected
|
||||||
missing profile is an error.
|
missing overlay is an error.
|
||||||
|
|
||||||
The profile is a separately policy-validated YAML mapping. It is loaded after the
|
The overlay is a separately policy-validated YAML mapping. It is loaded after the
|
||||||
base import graph is composed and its schema version is checked, but before defaults
|
base import graph is composed and its schema version is checked, but before defaults
|
||||||
and typed decoding. Profile bytes, nodes, and source records count toward the same
|
and typed decoding. Overlay bytes, nodes, and source records count toward the same
|
||||||
aggregate limits as base inputs. Its canonical path must remain within the configured
|
aggregate limits as base inputs. Its canonical path must remain within the configured
|
||||||
root and must not duplicate a file already present in the base graph.
|
root and must not duplicate a file already present in the base graph.
|
||||||
|
|
||||||
Overlay is intentionally distinct from strict composition:
|
Overlay is intentionally distinct from strict composition:
|
||||||
|
|
||||||
| Existing lower node | Incoming profile node | Result |
|
| Existing lower node | Incoming overlay node | Result |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Absent | Value | Retain the profile value |
|
| Absent | Value | Retain the overlay value |
|
||||||
| Value | Absent | Retain the lower value |
|
| Value | Absent | Retain the lower value |
|
||||||
| Mapping | Mapping | Recurse by mapping key |
|
| Mapping | Mapping | Recurse by mapping key |
|
||||||
| Any other pair of present nodes | Any other pair of present nodes | Replace the entire lower node |
|
| Any other pair of present nodes | Any other pair of present nodes | Replace the entire lower node |
|
||||||
|
|
||||||
Scalars, sequences, and null are atomic. Thus a profile sequence replaces rather
|
Scalars, sequences, and null are atomic. Thus an overlay sequence replaces rather
|
||||||
than extends a base sequence, `[]` clears it, null is an explicit replacement, and
|
than extends a base sequence, `[]` clears it, null is an explicit replacement, and
|
||||||
cross-kind replacement is allowed when the final application schema accepts it.
|
cross-kind replacement is allowed when the final application schema accepts it.
|
||||||
Replacing a subtree removes all displaced descendants from effective presence and
|
Replacing a subtree removes all displaced descendants from effective presence and
|
||||||
origin data; history retains the displaced definitions and identifies the parent
|
origin data; history retains the displaced definitions and identifies the parent
|
||||||
replacement. Surviving profile paths have profile origins, while recursively shared
|
replacement. Surviving overlay paths have overlay origins, while recursively shared
|
||||||
mapping containers retain their contributing locations.
|
mapping containers retain their contributing locations.
|
||||||
|
|
||||||
A profile may not contain `_configkit` anywhere, contribute the configured schema
|
An overlay may not contain `configkit` anywhere, contribute the configured schema
|
||||||
version, or replace an ancestor of that version with a scalar or null. These rules
|
version, or replace an ancestor of that version with a scalar or null. These rules
|
||||||
apply even if the supplied version text would be unchanged. Profiles cannot mask a
|
apply even if the supplied version text would be unchanged. Overlays cannot mask a
|
||||||
base parse, composition, or version failure because those checks run first.
|
base parse, composition, or version failure because those checks run first.
|
||||||
|
|
||||||
## Explicit CLI overrides
|
## Explicit CLI overrides
|
||||||
@@ -434,8 +483,8 @@ must never contain the raw argument or configured value. General environment
|
|||||||
variable overlays are an explicit non-goal: environment access is reserved for
|
variable overlays are an explicit non-goal: environment access is reserved for
|
||||||
typed secret references.
|
typed secret references.
|
||||||
|
|
||||||
Overrides apply after the selected profile and before typed defaults/decoding, so
|
Overrides apply after the selected overlay and before typed defaults/decoding, so
|
||||||
the input precedence is application defaults < composed base < selected profile <
|
the input precedence is application defaults < composed base < selected overlay <
|
||||||
CLI. Recorded resolution, typed secret lookup, and semantic validation follow the
|
CLI. Recorded resolution, typed secret lookup, and semantic validation follow the
|
||||||
effective override tree. Overrides also work with `NoSource`.
|
effective override tree. Overrides also work with `NoSource`.
|
||||||
|
|
||||||
@@ -452,12 +501,12 @@ All records are detached and validated before any is applied. Root and explicit
|
|||||||
sequence-index paths are invalid; parsed numeric tokens remain mapping keys. A path
|
sequence-index paths are invalid; parsed numeric tokens remain mapping keys. A path
|
||||||
may create missing mapping ancestors but cannot traverse an existing scalar, null,
|
may create missing mapping ancestors but cannot traverse an existing scalar, null,
|
||||||
or sequence. Duplicate and ancestor/descendant-overlapping records are rejected.
|
or sequence. Duplicate and ancestor/descendant-overlapping records are rejected.
|
||||||
Neither paths nor values may create `_configkit`, and no record may overlap the
|
Neither paths nor values may create `configkit`, and no record may overlap the
|
||||||
configured schema-version path. Unknown fields and effective type mismatches remain
|
configured schema-version path. Unknown fields and effective type mismatches remain
|
||||||
ordinary strict-decoder errors.
|
ordinary strict-decoder errors.
|
||||||
|
|
||||||
At most 1,024 records are accepted, and each record creates one synthetic CLI
|
At most 1,024 records are accepted, and each record creates one synthetic CLI
|
||||||
source after file/profile sources in supplied order. Override sources share the
|
source after file/overlay sources in supplied order. Override sources share the
|
||||||
1,024-source ceiling. A record has an 8 MiB string-byte limit; override strings and
|
1,024-source ceiling. A record has an 8 MiB string-byte limit; override strings and
|
||||||
source bytes share the 64 MiB aggregate limit. Synthetic mapping keys and values
|
source bytes share the 64 MiB aggregate limit. Synthetic mapping keys and values
|
||||||
count toward the existing 1,000,000-node and depth-256 ceilings. Cycles, named or
|
count toward the existing 1,000,000-node and depth-256 ceilings. Cycles, named or
|
||||||
@@ -465,7 +514,7 @@ unsupported types, nonfinite floats, and limit excesses fail with safe `override
|
|||||||
diagnostics. Diagnostics and provenance identify the safe `Flag` source, never raw
|
diagnostics. Diagnostics and provenance identify the safe `Flag` source, never raw
|
||||||
arguments or values.
|
arguments or values.
|
||||||
|
|
||||||
Override origins are explicit CLI origins. History retains replaced base/profile
|
Override origins are explicit CLI origins. History retains replaced base/overlay
|
||||||
definitions and records equal-value replacements as well as structural changes.
|
definitions and records equal-value replacements as well as structural changes.
|
||||||
Caller-owned maps and slices are snapshotted during loading and must not be mutated
|
Caller-owned maps and slices are snapshotted during loading and must not be mutated
|
||||||
concurrently with the call. The executable `ExampleOverride` shows translating a
|
concurrently with the call. The executable `ExampleOverride` shows translating a
|
||||||
@@ -475,12 +524,18 @@ zero.
|
|||||||
## Explicit imports
|
## Explicit imports
|
||||||
|
|
||||||
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
|
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
|
||||||
`_configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
|
`configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
|
||||||
application decoding. Each sequence item is a mapping with exactly one nonempty
|
application decoding. Each sequence item is a mapping with exactly one nonempty
|
||||||
string `path` or `glob` plus an optional boolean `optional`; all other shapes and
|
string `path` or `glob` plus an optional boolean `optional`; all other shapes and
|
||||||
fields are `import.invalid`. A `_configkit` key below the root is invalid. With
|
fields are `import.invalid`. A `configkit` key below the root is invalid. With
|
||||||
imports disabled, any occurrence remains `import.disabled`.
|
imports disabled, any occurrence remains `import.disabled`.
|
||||||
|
|
||||||
|
The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0.
|
||||||
|
There is no legacy alias: `_configkit` is ordinary application data, subject to
|
||||||
|
normal schema validation, and does not trigger imports. When upgrading, rename
|
||||||
|
directive keys in root files and imported fragments, and rename any application
|
||||||
|
fields previously named `configkit` to avoid the newly reserved key.
|
||||||
|
|
||||||
Path declarations use slash-separated relative paths. Absolute paths and network
|
Path declarations use slash-separated relative paths. Absolute paths and network
|
||||||
URLs are invalid. Each path resolves from its declaring file's canonical directory,
|
URLs are invalid. Each path resolves from its declaring file's canonical directory,
|
||||||
and files are traversed depth-first in declaration order: a file contributes its own
|
and files are traversed depth-first in declaration order: a file contributes its own
|
||||||
@@ -600,8 +655,10 @@ and redacted inspection.
|
|||||||
|
|
||||||
Schema path traversal uses these same bindings. Explicit key segments address
|
Schema path traversal uses these same bindings. Explicit key segments address
|
||||||
mappings, explicit index segments address sequences, and parsed numeric pointer
|
mappings, explicit index segments address sequences, and parsed numeric pointer
|
||||||
tokens become indices only in a sequence. Sequence indices cannot have leading
|
tokens become indices only in a sequence. Sequence indices must use canonical
|
||||||
zeroes; fixed-array indices are bounds checked. Dynamic `any` and inline-map entries
|
nonnegative decimal spelling: no signs or leading zeroes except `0` itself.
|
||||||
|
Tokens such as `+0` and `-0` remain literal keys in mappings but do not identify
|
||||||
|
sequence elements. Fixed-array indices are bounds checked. Dynamic `any` and inline-map entries
|
||||||
remain schema-valid for application-defined descendants, but dynamic values cannot
|
remain schema-valid for application-defined descendants, but dynamic values cannot
|
||||||
introduce typed secret references.
|
introduce typed secret references.
|
||||||
|
|
||||||
@@ -655,7 +712,11 @@ keys but preserve the strict schema of their values; inline maps accept unmatche
|
|||||||
keys according to their element descriptor. Multiple independent field failures are
|
keys according to their element descriptor. Multiple independent field failures are
|
||||||
collected without publishing the partially decoded object.
|
collected without publishing the partially decoded object.
|
||||||
|
|
||||||
Booleans and strings are not coerced. Integer destinations require integer syntax
|
Booleans and strings are not generally coerced. At exactly a configured
|
||||||
|
`VersionRule.Path`, strict decimal-integer input may decode into a plain `string`, a
|
||||||
|
named string without a text codec, or pointer wrappers around those types. The
|
||||||
|
exception is load-local and does not apply to unrelated fields or loads without a
|
||||||
|
version rule. Integer destinations require integer syntax
|
||||||
and a value in the destination type's range. Float destinations accept finite JSON
|
and a value in the destination type's range. Float destinations accept finite JSON
|
||||||
numbers representable by their destination precision. Dynamic numbers become
|
numbers representable by their destination precision. Dynamic numbers become
|
||||||
`int64` for integer syntax and `float64` for fractional or exponent syntax, with
|
`int64` for integer syntax and `float64` for fractional or exponent syntax, with
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Consumer Adoption Guide
|
# Consumer Adoption Guide
|
||||||
|
|
||||||
ConfigKit removes parser setup, strict decoding, presence bookkeeping, import and
|
ConfigKit removes parser setup, strict decoding, presence bookkeeping, import and
|
||||||
profile traversal, precedence, provenance, secret lookup, and safe inspection from
|
selected-overlay traversal, precedence, provenance, secret lookup, and safe inspection from
|
||||||
applications. Consumers still own their configuration structs, defaults, semantic
|
applications. Consumers still own their configuration structs, defaults, semantic
|
||||||
validation, schema-version policy, CLI parsing, runtime matching and conversion,
|
validation, schema-version policy, CLI parsing, runtime matching and conversion,
|
||||||
resource checks, remote acquisition, and service initialization.
|
resource checks, remote acquisition, and service initialization.
|
||||||
@@ -12,7 +12,7 @@ four sanitized shapes using only ConfigKit and the Go standard library:
|
|||||||
| Shape | Demonstrated fit | Application code deliberately retained |
|
| Shape | Demonstrated fit | Application code deliberately retained |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Narratio-like | Fresh defaults, explicit partial imports, artifact maps, recorded inherited defaults, normalization, and source ownership | Artifact meaning and the separate `model_profile` domain field |
|
| Narratio-like | Fresh defaults, explicit partial imports, artifact maps, recorded inherited defaults, normalization, and source ownership | Artifact meaning and the separate `model_profile` domain field |
|
||||||
| Notarius-like | Canonical object bindings, a string schema version, strict dynamic options, and `int64` dynamic integers | Catalog selection, allowed option keys, range-checked conversion to runtime `int` |
|
| Notarius-like | Canonical object bindings, a string-backed schema version accepting string or integer YAML, strict dynamic options, and `int64` dynamic integers | Catalog selection, allowed option keys, range-checked conversion to runtime `int` |
|
||||||
| WeatherReporter-like | Defaults-only loading, report maps, canonical module objects, typed option unions, explicit CLI records, and typed secret startup | Report/module compatibility, rejection of explicitly empty domain lists, CLI parsing, and startup ordering |
|
| WeatherReporter-like | Defaults-only loading, report maps, canonical module objects, typed option unions, explicit CLI records, and typed secret startup | Report/module compatibility, rejection of explicitly empty domain lists, CLI parsing, and startup ordering |
|
||||||
| Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file |
|
| Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file |
|
||||||
|
|
||||||
@@ -20,6 +20,11 @@ The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
|
|||||||
executable examples and ordinary tests. It contains no private application data,
|
executable examples and ordinary tests. It contains no private application data,
|
||||||
credentials, network calls, or dependency on a sibling checkout.
|
credentials, network calls, or dependency on a sibling checkout.
|
||||||
|
|
||||||
|
The Notarius-shaped fixtures deliberately load both `version: 4` and
|
||||||
|
`version: "4"` into the same string-backed application field. Its version checker
|
||||||
|
still owns the supported identifier; fractional numeric input and unsupported
|
||||||
|
integer identifiers remain negative examples.
|
||||||
|
|
||||||
## Happy path
|
## Happy path
|
||||||
|
|
||||||
An application defines ordinary types and passes one explicit source. Optional
|
An application defines ordinary types and passes one explicit source. Optional
|
||||||
@@ -29,7 +34,7 @@ features appear only when the application needs them:
|
|||||||
result, err := configkit.Load[Config](configkit.File(path), configkit.Options[Config]{
|
result, err := configkit.Load[Config](configkit.File(path), configkit.Options[Config]{
|
||||||
Definition: rules,
|
Definition: rules,
|
||||||
Imports: useDeclaredImports,
|
Imports: useDeclaredImports,
|
||||||
Profile: selectedConfigProfile,
|
Overlay: selectedConfigOverlay,
|
||||||
Overrides: explicitlySetFlags,
|
Overrides: explicitlySetFlags,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -38,8 +43,8 @@ if err != nil {
|
|||||||
return start(result.Config)
|
return start(result.Config)
|
||||||
```
|
```
|
||||||
|
|
||||||
Simple consumers use neither imports nor profiles. A complex consumer can opt into
|
Simple consumers use neither imports nor selected overlays. A complex consumer can opt into
|
||||||
both without redefining their syntax. ConfigKit profiles are configuration overlays;
|
both without redefining their syntax. ConfigKit overlays are configuration overlays;
|
||||||
they do not select or replace an application's LLM, model, pipeline, report, or
|
they do not select or replace an application's LLM, model, pipeline, report, or
|
||||||
execution profile concepts.
|
execution profile concepts.
|
||||||
|
|
||||||
@@ -51,6 +56,24 @@ validate keys and ranges and convert only at their runtime boundary. Neither sha
|
|||||||
needs YAML reserialization, a parser hook, a ConfigKit registry, or a generic subtree
|
needs YAML reserialization, a parser hook, a ConfigKit registry, or a generic subtree
|
||||||
conversion API.
|
conversion API.
|
||||||
|
|
||||||
|
## Source-aware application path policy
|
||||||
|
|
||||||
|
[`LoadNotariusPaths`](../../examples/consumers/consumers.go) is the maintained
|
||||||
|
path-bearing example. Its application resolver uses `Resolution.InputInfo` and
|
||||||
|
`Resolution.Source` before normalizing: relative CLI input uses an explicitly
|
||||||
|
supplied invocation directory, retained defaults use the canonical main source,
|
||||||
|
and a file-authored scalar uses its one declaring source. Absolute paths remain
|
||||||
|
unchanged. Its executable test covers the matching effective YAML and explanation
|
||||||
|
history, source-file ownership, and a symlink opened through an alias; canonical
|
||||||
|
target-file directories, rather than the alias directory or process working
|
||||||
|
directory, are the selected bases.
|
||||||
|
|
||||||
|
The selected example policy leaves a relative value unchanged and emits a warning
|
||||||
|
when a CLI base is absent or file provenance is missing or composite. That is an
|
||||||
|
application choice, not a ConfigKit fallback: consumers can instead reject it or
|
||||||
|
choose another explicit base. The example intentionally performs neither target
|
||||||
|
existence checks nor confinement checks.
|
||||||
|
|
||||||
## Intentional migration changes
|
## Intentional migration changes
|
||||||
|
|
||||||
The examples preserve domain meaning while adopting one configuration convention:
|
The examples preserve domain meaning while adopting one configuration convention:
|
||||||
@@ -61,8 +84,9 @@ The examples preserve domain meaning while adopting one configuration convention
|
|||||||
domain rule. Loaded and constructed values use the same validator.
|
domain rule. Loaded and constructed values use the same validator.
|
||||||
- Canonical module and object bindings replace string shorthand. Unknown fields and
|
- Canonical module and object bindings replace string shorthand. Unknown fields and
|
||||||
incompatible option shapes fail at their semantic paths.
|
incompatible option shapes fail at their semantic paths.
|
||||||
- Schema versions are strings. Consumers with integer versions migrate their files
|
- Schema versions remain string-backed in application types. A configured
|
||||||
instead of enabling another scalar convention.
|
`VersionRule` accepts string or strict decimal-integer YAML at its exact path and
|
||||||
|
supplies the spelling to both the checker and a plain string destination.
|
||||||
- Integers inside dynamic options are portable `int64` values. Runtime `int`
|
- Integers inside dynamic options are portable `int64` values. Runtime `int`
|
||||||
conversion is explicit and range checked.
|
conversion is explicit and range checked.
|
||||||
- Strict YAML intentionally rejects duplicate keys, aliases, merge keys, unsupported
|
- Strict YAML intentionally rejects duplicate keys, aliases, merge keys, unsupported
|
||||||
@@ -76,6 +100,16 @@ The examples preserve domain meaning while adopting one configuration convention
|
|||||||
blocked empty variables remain blocked, and ordinary `Load` never changes the
|
blocked empty variables remain blocked, and ordinary `Load` never changes the
|
||||||
process environment.
|
process environment.
|
||||||
|
|
||||||
|
### Selected-overlay migration
|
||||||
|
|
||||||
|
To adopt the current selected-overlay API, rename `Options.Profile`,
|
||||||
|
`LayerProfile`, and `CodeProfile*` references to their `Overlay` equivalents; move
|
||||||
|
selected files from `profiles/<name>.yml` to `overlays/<name>.yml`; update any
|
||||||
|
handling of the `overlay` layer and `overlay.*` diagnostic strings; then rebuild.
|
||||||
|
The [configuration contract](../config.md#selected-overlays) is the canonical
|
||||||
|
behavioral reference. Application-owned profile concepts, such as `model_profile`,
|
||||||
|
are unchanged, and any CLI flag rename remains the application's decision.
|
||||||
|
|
||||||
These examples are migration evidence, not completed changes to Narratio, Notarius,
|
These examples are migration evidence, not completed changes to Narratio, Notarius,
|
||||||
WeatherReporter, or Seriatim. Updating those repositories, their deployed YAML, CLI
|
WeatherReporter, or Seriatim. Updating those repositories, their deployed YAML, CLI
|
||||||
wiring, runtime catalogs, and operational documentation remains separate downstream
|
wiring, runtime catalogs, and operational documentation remains separate downstream
|
||||||
@@ -89,7 +123,7 @@ focused behavioral owner:
|
|||||||
| Roadmap surface | Maintained owner |
|
| Roadmap surface | Maintained owner |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `Load`, `NoSource`, validation, versions, resolution, `SecretEnv`, overrides, and explanation | Executable examples in [`example_test.go`](../../example_test.go) |
|
| `Load`, `NoSource`, validation, versions, resolution, `SecretEnv`, overrides, and explanation | Executable examples in [`example_test.go`](../../example_test.go) |
|
||||||
| Source/import/profile pipeline rules and artifact defaults | Narratio-shaped executable example and fixture |
|
| Source/import/selected-overlay pipeline rules and artifact defaults | Narratio-shaped executable example and fixture |
|
||||||
| Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests |
|
| Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests |
|
||||||
| `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` |
|
| `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` |
|
||||||
| Results, metadata, diagnostics, effective output, and writer failures | Focused root-package load, provenance, rendering, and explanation tests |
|
| Results, metadata, diagnostics, effective output, and writer failures | Focused root-package load, provenance, rendering, and explanation tests |
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
# Development
|
# Development
|
||||||
|
|
||||||
This is the contributor entry point for ConfigKit. Read the task-specific owners
|
Start here when contributing to ConfigKit. Read the task-specific owners below
|
||||||
below before making changes. Its public
|
before making changes. The [component overview](internal/overview.md) maps
|
||||||
loader currently covers strict selected sources, explicit path/glob import graphs,
|
implementation responsibilities; the [configuration contract](config.md) owns
|
||||||
composition, selected profile overlays, typed defaults, schema-version checks,
|
public behavior.
|
||||||
explicit CLI overrides, recorded application resolution, semantic validation,
|
|
||||||
layered metadata, secret
|
|
||||||
classification, typed environment references, explicit secrets bootstrap, and
|
|
||||||
redacted effective rendering and historical value explanation. The
|
|
||||||
[component overview](internal/overview.md) owns the implementation inventory.
|
|
||||||
|
|
||||||
## Initial orientation
|
## Initial orientation
|
||||||
|
|
||||||
1. Inspect the working tree and preserve unrelated changes.
|
1. Inspect the working tree and preserve unrelated changes.
|
||||||
|
Agents must read and follow the [agent policy](policy/agents.md) before
|
||||||
|
recommending, planning, or implementing changes.
|
||||||
2. Read the architecture policy for code or design work.
|
2. Read the architecture policy for code or design work.
|
||||||
3. Read the policy, contract, and internal documents applicable to the task.
|
3. Read the policy, contract, and internal documents applicable to the task.
|
||||||
4. Inspect relevant implementation and tests where they exist before changing them.
|
4. Inspect the relevant implementation and behavioral tests before changing them.
|
||||||
|
|
||||||
## Task-specific reading guide
|
## Task-specific reading guide
|
||||||
|
|
||||||
@@ -23,26 +20,49 @@ redacted effective rendering and historical value explanation. The
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Repository orientation or component responsibility | [Architecture policy](policy/architecture.md) and [component overview](internal/overview.md) |
|
| Repository orientation or component responsibility | [Architecture policy](policy/architecture.md) and [component overview](internal/overview.md) |
|
||||||
| Documentation changes | [Documentation policy](policy/documentation.md) and the owning document |
|
| Documentation changes | [Documentation policy](policy/documentation.md) and the owning document |
|
||||||
| Architecture decisions or future work | [Documentation policy](policy/documentation.md), [initial target record](roadmap/initial.md), and relevant ADRs listed below |
|
| Architecture decisions or future work | [Documentation policy](policy/documentation.md), [deferred scope](roadmap/deferred.md), and relevant ADRs listed below |
|
||||||
| Code and test changes | [Architecture policy](policy/architecture.md), [testing policy](policy/testing.md), and owning contracts/tests as they are introduced |
|
| Code and test changes | [Architecture policy](policy/architecture.md), [testing policy](policy/testing.md), and the owning contract and tests from the map below |
|
||||||
| Examples or copyable assets | The owning implemented contract and [consumer adoption guide](consumers/README.md) |
|
| Examples or copyable assets | The owning implemented contract and [consumer adoption guide](consumers/README.md) |
|
||||||
| Release preparation | [Release policy](policy/release.md), [release procedure](release.md), and [release-readiness evidence](roadmap/release-readiness.md) |
|
| Release preparation | [Release policy](policy/release.md) and [release procedure](release.md) |
|
||||||
|
|
||||||
Accepted decisions:
|
Accepted decisions:
|
||||||
|
|
||||||
- [Separate composition and overlay](adr/0001-separate-composition-and-overlay.md)
|
- [Separate composition and overlay](adr/0001-separate-composition-and-overlay.md)
|
||||||
- [Own YAML semantics behind a private parser boundary](adr/0002-own-yaml-semantics.md)
|
- [Own YAML semantics behind a private parser boundary](adr/0002-own-yaml-semantics.md)
|
||||||
- [Preserve presence and record application resolution](adr/0003-preserve-presence-and-resolution-history.md)
|
- [Preserve presence and record application resolution](adr/0003-preserve-presence-and-resolution-history.md)
|
||||||
|
|
||||||
- [Secret references and explicit bootstrap](adr/0004-secret-references-and-explicit-bootstrap.md)
|
- [Secret references and explicit bootstrap](adr/0004-secret-references-and-explicit-bootstrap.md)
|
||||||
- [Explicit overrides and safe explanation](adr/0005-explicit-overrides-and-safe-explanation.md)
|
- [Explicit overrides and safe explanation](adr/0005-explicit-overrides-and-safe-explanation.md)
|
||||||
|
- [Keep the public facade root-owned](adr/0006-public-facade.md)
|
||||||
|
|
||||||
For cross-cutting changes, follow every applicable row. The
|
For cross-cutting changes, follow every applicable row. Completed implementation
|
||||||
[implementation plan](roadmap/implementation.md) owns the ordered stages and their
|
and remediation plans are preserved in Git history at `cb5b435`; use current
|
||||||
acceptance checks and recorded implementation evidence.
|
contracts and the component overview for ongoing work. For example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git show cb5b435:docs/roadmap/remediation.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Release-candidate, publication, and verification work follows the operational
|
||||||
|
[release procedure](release.md).
|
||||||
|
|
||||||
|
The completed string-and-integer schema-version implementation and remediation
|
||||||
|
records are preserved in Git history at `244104f`. The
|
||||||
|
[configuration contract](config.md#application-schema-versions) owns current behavior.
|
||||||
|
|
||||||
|
The completed source-origins and public-facade roadmaps, implementation plan, and
|
||||||
|
resolved review findings are preserved in Git history at `5760614`, for example
|
||||||
|
`git show 5760614:docs/roadmap/remediation.md`. The earlier source-origins staged
|
||||||
|
plan remains at `28e7fc5:docs/roadmap/implementation.md`. Current resolution
|
||||||
|
behavior belongs to the [configuration contract](config.md#recorded-application-resolution),
|
||||||
|
and current facade ownership belongs to the [component overview](internal/overview.md).
|
||||||
|
|
||||||
## Baseline validation
|
## Baseline validation
|
||||||
|
|
||||||
|
The completed overlay rename plan and release-workflow simplification record are
|
||||||
|
preserved in Git history at `73ce6e9` under `docs/roadmap/`. Current overlay
|
||||||
|
behavior belongs to the [configuration contract](config.md#selected-overlays),
|
||||||
|
and release operations belong to the [release procedure](release.md).
|
||||||
|
|
||||||
Install Go 1.25.0 or a supported patched/newer Go toolchain. Acquire declared
|
Install Go 1.25.0 or a supported patched/newer Go toolchain. Acquire declared
|
||||||
dependencies explicitly before entering an offline development or validation
|
dependencies explicitly before entering an offline development or validation
|
||||||
environment:
|
environment:
|
||||||
@@ -51,27 +71,53 @@ environment:
|
|||||||
go mod download
|
go mod download
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the library baseline from the repository root:
|
Run the everyday check with Bash 3.2 or newer and Git:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test ./...
|
./scripts/check.sh
|
||||||
go vet ./...
|
|
||||||
go build ./...
|
|
||||||
git diff --check
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The default tests must remain deterministic and runnable without network access or
|
[The script](../scripts/check.sh) runs from the repository root regardless of the
|
||||||
credentials after dependency acquisition. Also inspect new untracked files, verify
|
invocation directory. It checks Go formatting without rewriting files, checks
|
||||||
changed local links and heading fragments, and review paths, examples, ownership,
|
staged/unstaged and untracked whitespace, then runs `go test ./...`,
|
||||||
and current-versus-planned claims. The commands above do not check links or
|
`go vet ./...`, and `go build ./...`, stopping on the first failure. It uses the
|
||||||
untracked content. Do not use an application binary build as the library baseline.
|
installed Go toolchain by default, honors an explicit `GOTOOLCHAIN`, and disables
|
||||||
Add executable-example and other checks when those artifacts are introduced; do
|
dependency downloads. Acquire dependencies and any selected toolchain beforehand.
|
||||||
not document nonexistent tools as operational.
|
Format reported Go files with `gofmt -w <files>`.
|
||||||
|
|
||||||
Once code exists, use focused checks while iterating and validation proportionate
|
The default suite includes executable examples, maintained consumer fixtures,
|
||||||
to the change under the testing policy. Documentation-only work does not require
|
local documentation-link checks, and the dependency import-boundary check.
|
||||||
Go checks unless it changes behavior-bearing examples or commands they validate.
|
It must stay deterministic and runnable without network access or credentials.
|
||||||
Release validation is a separate, stricter gate governed by release policy.
|
Still inspect new/untracked content and review paths, examples, ownership, and
|
||||||
|
current-versus-planned claims; automated checks do not replace content review.
|
||||||
|
|
||||||
|
Use focused checks while iterating, with validation proportionate to the change
|
||||||
|
under the testing policy. Documentation-only work normally needs the local-link
|
||||||
|
check below and whitespace review; behavior-bearing examples and commands also
|
||||||
|
need execution. Use `go test -race ./...` for ownership or concurrency changes.
|
||||||
|
The [release checker](release.md#4-validate-the-source-candidate) performs one
|
||||||
|
minimum-toolchain race-suite run, vet, module hygiene, and Linux cross-builds.
|
||||||
|
It already covers examples, links, and package boundaries; do not additionally
|
||||||
|
run the everyday checker just to repeat those checks. CI never gates a release.
|
||||||
|
|
||||||
|
## Task-to-test map
|
||||||
|
|
||||||
|
Commands run from the repository root after dependency acquisition. These are
|
||||||
|
starting points for focused iteration; run the everyday check before handing off
|
||||||
|
code changes. Use the component overview to find the corresponding implementation.
|
||||||
|
|
||||||
|
| Task | Behavioral test owners | Focused command |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
|
||||||
|
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
|
||||||
|
| Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` |
|
||||||
|
| Schema, defaults, projection, cloning | [Schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
|
||||||
|
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
|
||||||
|
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
|
||||||
|
| Secrets, bootstrap, redaction, explanation | [References](../internal/engine/secret_reference_test.go), [bootstrap](../internal/engine/secrets_test.go), [rendering](../internal/engine/render_test.go), [explanation](../internal/engine/explain_test.go) | `go test ./internal/engine -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` |
|
||||||
|
| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` |
|
||||||
|
| Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestLocalDocumentationLinks\|TestDependencyImportBoundary\|TestPublicFacadeBoundary'` |
|
||||||
|
| Check-command behavior | [Shell regression](../release_check_test.go) | `go test . -run '^TestCheckScripts'` |
|
||||||
|
|
||||||
## Fuzz validation
|
## Fuzz validation
|
||||||
|
|
||||||
@@ -79,7 +125,7 @@ Run the bounded fuzz targets independently from the repository root. These exact
|
|||||||
commands keep each campaign short and constrain concurrent workers:
|
commands keep each campaign short and constrain concurrent workers:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test . -run='^$' -fuzz='^FuzzParsePath$' -fuzztime=30s -parallel=2
|
go test ./internal/engine -run='^$' -fuzz='^FuzzParsePath$' -fuzztime=30s -parallel=2
|
||||||
go test ./internal/yaml -run='^$' -fuzz='^FuzzParseDocument$' -fuzztime=30s -parallel=2
|
go test ./internal/yaml -run='^$' -fuzz='^FuzzParseDocument$' -fuzztime=30s -parallel=2
|
||||||
go test . -run='^$' -fuzz='^FuzzCompositionAndOverlay$' -fuzztime=30s -parallel=2
|
go test ./internal/engine -run='^$' -fuzz='^FuzzCompositionAndOverlay$' -fuzztime=30s -parallel=2
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -115,8 +115,9 @@ Assigned to explicit import traversal and glob handling:
|
|||||||
Canonical paths—not `os.SameFile`—define identity, so hard links remain distinct.
|
Canonical paths—not `os.SameFile`—define identity, so hard links remain distinct.
|
||||||
|
|
||||||
Path-import implementation record (2026-09-06): `splitImportDirectives`,
|
Path-import implementation record (2026-09-06): `splitImportDirectives`,
|
||||||
`parseImportDeclaration`, `importFilesystem`, `importTraversal.visitPath`, and
|
`parseImportDeclaration` (now in `directives.go`), `importFilesystem` (now in
|
||||||
`loadImportedDocuments` in `imports.go` adapt the assigned envelope, loading, source,
|
`filesystem.go`), and `importTraversal.visitPath`/`loadImportedDocuments` in
|
||||||
|
`imports.go` adapt the assigned envelope, loading, source,
|
||||||
and failure mechanics. The corresponding ConfigKit tests adapt the assigned
|
and failure mechanics. The corresponding ConfigKit tests adapt the assigned
|
||||||
declaration-order, disjoint composition, keyed-entry, all-source conflict, unsafe
|
declaration-order, disjoint composition, keyed-entry, all-source conflict, unsafe
|
||||||
target, alias, imported parse/schema, and relative-resolution cases. ConfigKit adds
|
target, alias, imported parse/schema, and relative-resolution cases. ConfigKit adds
|
||||||
@@ -136,9 +137,11 @@ permission failures, explicit `conf.d` use, and entry/candidate budgets. This wo
|
|||||||
has no Narratio production equivalent; it uses the selected standard-library
|
has no Narratio production equivalent; it uses the selected standard-library
|
||||||
`path.Match` and bounded `File.ReadDir` adaptation rather than its flat loader.
|
`path.Match` and bounded `File.ReadDir` adaptation rather than its flat loader.
|
||||||
|
|
||||||
### Profiles and overlay
|
### Upstream profiles and ConfigKit selected overlays
|
||||||
|
|
||||||
Assigned to selected-profile overlay:
|
The following upstream symbols and case names retain their original profile
|
||||||
|
terminology. ConfigKit adapts their selected-overlay behavior without renaming the
|
||||||
|
upstream evidence:
|
||||||
|
|
||||||
- Production: `mergeOverlayComposition`, `mergeOverlayNodes`,
|
- Production: `mergeOverlayComposition`, `mergeOverlayNodes`,
|
||||||
`selectPipelineProfile`, `loadPipelineProfileOverlays`, and
|
`selectPipelineProfile`, `loadPipelineProfileOverlays`, and
|
||||||
@@ -152,19 +155,21 @@ Assigned to selected-profile overlay:
|
|||||||
`TestLoadPipelineProfilePairResolvesIndependentEffectivePipelines`.
|
`TestLoadPipelineProfilePairResolvesIndependentEffectivePipelines`.
|
||||||
- Preserve: recursive mapping overlay, atomic scalar/sequence replacement,
|
- Preserve: recursive mapping overlay, atomic scalar/sequence replacement,
|
||||||
deterministic selection failures, source ownership, and independent results.
|
deterministic selection failures, source ownership, and independent results.
|
||||||
- Adapt: load only the selected fixed profile path; lowercase `default` means no
|
- Adapt: load only the selected fixed overlay path; lowercase `default` means no
|
||||||
overlay. ConfigKit permits null and cross-kind replacement and does not support
|
selection. ConfigKit permits null and cross-kind replacement and does not support
|
||||||
declared defaults, inheritance, arbitrary profile paths, or validating every
|
declared defaults, inheritance, arbitrary overlay paths, or validating every
|
||||||
unselected overlay.
|
unselected overlay.
|
||||||
|
|
||||||
Implementation record (2026-09-06): `overlayDocuments`, `mergeOverlayMappings`,
|
Implementation record (2026-09-06, updated 2026-09-23): `overlayDocuments`,
|
||||||
`loadProfileDocument`, and `profileDirectiveDiagnostics` adapt the assigned merge,
|
`mergeOverlayMappings`, `loadSelectedOverlayDocument`, and
|
||||||
selection, loading, and ownership mechanics. `TestOverlayDocumentsRecursesMappingsAndReplacesAtomicSubtrees`,
|
`overlayDirectiveDiagnostics` adapt the assigned merge, selection, loading, and
|
||||||
`TestLoadAppliesSelectedProfileAfterBaseAndBeforeDefaults`, profile selection/error,
|
ownership mechanics. `TestOverlayDocumentsRecursesMappingsAndReplacesAtomicSubtrees`,
|
||||||
directive/version, containment, and budget cases adapt the assigned tests. ConfigKit
|
`TestLoadAppliesSelectedOverlayAfterBaseAndBeforeDefaults`, selected-overlay
|
||||||
|
selection/error, directive/version, containment, and budget cases adapt the assigned
|
||||||
|
tests. ConfigKit
|
||||||
uses exactly one fixed selected `.yml` source, permits null and cross-kind replacement,
|
uses exactly one fixed selected `.yml` source, permits null and cross-kind replacement,
|
||||||
retains displaced descendant history, rejects any profile directive or version-path
|
retains displaced descendant history, rejects any overlay directive or version-path
|
||||||
contribution, and counts the profile in aggregate budgets. Narratio's declared
|
contribution, and counts the overlay in aggregate budgets. Narratio's declared
|
||||||
default, multi-overlay validation, inheritance-like application conventions, and
|
default, multi-overlay validation, inheritance-like application conventions, and
|
||||||
null/kind restrictions were not retained.
|
null/kind restrictions were not retained.
|
||||||
|
|
||||||
@@ -195,7 +200,7 @@ previously extracted source claims, overlay displacement records, typed baseline
|
|||||||
and audited resolution snapshots in `load_metadata.go`. The representative
|
and audited resolution snapshots in `load_metadata.go`. The representative
|
||||||
`TestLayeredProvenanceAgreesWithResolvedSnapshot` adapts Narratio's stable ownership,
|
`TestLayeredProvenanceAgreesWithResolvedSnapshot` adapts Narratio's stable ownership,
|
||||||
profile ownership, deterministic effective projection, and side-effect-free
|
profile ownership, deterministic effective projection, and side-effect-free
|
||||||
inspection cases across one default/import/profile/resolution workflow. ConfigKit
|
inspection cases across one default/import/overlay/resolution workflow. ConfigKit
|
||||||
adds chronological descendant history for parent and whole-sequence operations,
|
adds chronological descendant history for parent and whole-sequence operations,
|
||||||
composite empty-mapping origins, canonical resolution reasons/dependencies, private
|
composite empty-mapping origins, canonical resolution reasons/dependencies, private
|
||||||
snapshot agreement, defensive nested copies, and concurrent load isolation. Value
|
snapshot agreement, defensive nested copies, and concurrent load isolation. Value
|
||||||
|
|||||||
@@ -1,68 +1,39 @@
|
|||||||
# Internal Component Overview
|
# Internal Component Overview
|
||||||
|
|
||||||
ConfigKit currently contains the root `configkit` package, which establishes the
|
ConfigKit exposes one supported root `configkit` package. The root owns public
|
||||||
module and package documentation boundary. Its implemented public primitives are:
|
named types, declaration comments, constructors, and explicit conversion/wrapper
|
||||||
|
code. It delegates loading and frozen inspection to `internal/engine` through
|
||||||
|
private state; public signatures and fields do not expose engine types.
|
||||||
|
|
||||||
- immutable semantic `Path` construction and strict JSON Pointer parsing;
|
Implemented component owners:
|
||||||
- detached provenance and diagnostic record types with empty `Metadata` queries;
|
|
||||||
- canonical, control-safe diagnostic rendering and structured `LoadError` access;
|
|
||||||
- opaque file, byte, reader, and no-input source construction and bounded reads;
|
|
||||||
- a parser-independent ordered tree in `internal/document`;
|
|
||||||
- the sole upstream YAML integration in `internal/yaml`, which translates validated
|
|
||||||
input into that owned tree and exposes no parser types outside its boundary;
|
|
||||||
- a private strict composer in `compose.go` that joins only disjoint document-tree
|
|
||||||
definitions, retains ordered claims and composite mapping origins, reports every
|
|
||||||
conflicting location, and never aliases an input tree;
|
|
||||||
- fixed selected-profile loading in `profile.go` and recursive, non-aliasing overlay
|
|
||||||
with displacement records in `overlay.go`;
|
|
||||||
- detached parser-neutral CLI override validation and synthetic-source application
|
|
||||||
in `override.go`, sharing overlay, source, byte, node, and depth policies;
|
|
||||||
- a per-operation private reflection descriptor graph in `schema.go` for supported
|
|
||||||
types, YAML field binding, inlining, secret tags, recursion, and semantic paths;
|
|
||||||
- descriptor-driven canonical projection, detached typed cloning, cycle/limit
|
|
||||||
enforcement, and structural comparison in `projection.go`;
|
|
||||||
- strict schema-driven decoding over detached defaults in `decode.go`, including
|
|
||||||
mapping/sequence semantics, null and numeric policy, safe codec errors, presence,
|
|
||||||
baseline/final trees, and initial default/input origins;
|
|
||||||
- public lifecycle coordination in `load.go`, bounded canonical import DFS in
|
|
||||||
`imports.go`, pattern-directed glob expansion in `glob.go`, version-report contexts
|
|
||||||
in `validation.go`, and detached layered metadata/history assembly in
|
|
||||||
`load_metadata.go`;
|
|
||||||
- recorded application defaulting and normalization in `resolution.go`, including
|
|
||||||
explicitness queries, helper verification, structural mutation audit, coalesced
|
|
||||||
intent, private before/after snapshots, and resolution metadata integration;
|
|
||||||
- post-resolution schema-aware report collection and validator mutation detection in
|
|
||||||
`validation.go`, completing the public callback lifecycle after composed input;
|
|
||||||
- opaque typed environment references and per-load name resolution in
|
|
||||||
`secret_reference.go`, including credential-free projection/cloning and private
|
|
||||||
validator-state auditing;
|
|
||||||
- explicit bounded startup environment bootstrap and safe partial reporting in
|
|
||||||
`secrets.go`;
|
|
||||||
- fixed-point tag/predicate/dependency sensitivity classification in `secret.go`;
|
|
||||||
- frozen-snapshot redaction and canonical effective output in `render.go`;
|
|
||||||
- bounded immutable input applications, safe historical replay, detached explanation
|
|
||||||
records, and deterministic human rendering in `explain.go`, with the
|
|
||||||
only upstream dumper integration isolated in `internal/yaml/write.go`;
|
|
||||||
- a separate maintained `examples/consumers` package with four sanitized consumer
|
|
||||||
shapes, fixtures, domain validators, and runtime-boundary functions; and
|
|
||||||
- a fail-fast source-candidate checker in `scripts/release-check.sh`, with local
|
|
||||||
documentation-link and public import-boundary gates owned by root-package tests.
|
|
||||||
|
|
||||||
The [configuration contract](../config.md) owns implemented input, schema, decoding,
|
- `internal/engine` owns source acquisition, composition, schema inspection,
|
||||||
loading lifecycle, path imports, composition, profile/CLI overlay, version checking,
|
decoding, lifecycle coordination, callback auditing, provenance/history,
|
||||||
recorded resolution, typed secret references/bootstrap, semantic validation,
|
secret resolution and bootstrap, and safe inspection rendering. It never
|
||||||
metadata, secret classification, redacted effective rendering, historical
|
imports the root package.
|
||||||
explanation, and ownership behavior.
|
- Root `Source`, `Path`, `Secret`, records, lifecycle callbacks, results, and
|
||||||
The [consumer adoption guide](../consumers/README.md) owns current application-fit
|
bootstrap reports are root-defined wrappers or records. Callback wrappers pass
|
||||||
and migration guidance.
|
only live engine context during a callback; `Result.Config` is detached and its
|
||||||
|
private inspection remains frozen independently.
|
||||||
|
- Root conversion files explicitly cross the path, record, callback, secret, and
|
||||||
|
error boundaries. There are no public aliases, anonymous engine embeddings, or
|
||||||
|
public engine bridge methods.
|
||||||
|
- `internal/document` owns the parser-independent ordered tree and cloning.
|
||||||
|
`internal/yaml` is the sole upstream YAML adapter and parser/dumper boundary.
|
||||||
|
- Engine mechanism tests own source, composition, decoding, callback, provenance,
|
||||||
|
secret, history, rendering, and bootstrap behavior. Root tests own public API
|
||||||
|
compatibility, facade integration, documentation links, and dependency/public
|
||||||
|
boundary checks. Consumer examples remain under `examples/consumers`.
|
||||||
|
|
||||||
The source-extraction inventory records the application foundations used by the
|
Dependency direction is root to engine to document/YAML support. The root may
|
||||||
implementation:
|
import only `internal/engine` among project internal production packages; engine
|
||||||
|
and its supporting packages do not import root. The root boundary test enforces
|
||||||
|
these constraints and rejects exported aliases, fields, or signatures involving
|
||||||
|
internal types.
|
||||||
|
|
||||||
- [Extraction inventory](extraction.md)
|
The [configuration contract](../config.md) owns implemented YAML and lifecycle
|
||||||
|
semantics. The [consumer adoption guide](../consumers/README.md) owns application
|
||||||
The [architecture policy](../policy/architecture.md) owns binding boundaries and
|
guidance. The [architecture policy](../policy/architecture.md) owns contributor
|
||||||
invariants. The [initial target record](../roadmap/initial.md) links current feature
|
boundaries, and [ADR 0006](../adr/0006-public-facade.md) records the facade
|
||||||
owners and preserves deferred scope; it is not an implementation inventory. See the
|
decision. See the [development guide](../development.md) for validation commands
|
||||||
[development guide](../development.md) for contributor orientation and validation
|
and [release procedure](../release.md) for source-candidate gates.
|
||||||
and the [release procedure](../release.md) for source-candidate gates.
|
|
||||||
|
|||||||
91
docs/policy/agents.md
Normal file
91
docs/policy/agents.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# Agent Policy
|
||||||
|
|
||||||
|
**Base Policy Version:** 1.1
|
||||||
|
|
||||||
|
**Last Updated**: 2026-09-23
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Agents working in this repository must read and adhere to the following house rules.
|
||||||
|
|
||||||
|
## Implementation Discipline
|
||||||
|
|
||||||
|
**KEY PRINCIPLES:** Write the simplest idiomatic code that satisfies the user's
|
||||||
|
request. Do not implicitly strengthen requirements, restrictions, or guarantees
|
||||||
|
beyond what the user actually requested.
|
||||||
|
|
||||||
|
Simplify the mechanism, not the user's choices.
|
||||||
|
|
||||||
|
**Keep implementation machinery proportionate.** Use straightforward,
|
||||||
|
idiomatic code and introduce only the machinery needed to support the requested
|
||||||
|
behavior and flexibility. Optimize for conceptual simplicity and
|
||||||
|
maintainability, not for the fewest lines, fewest options, or narrowest product
|
||||||
|
surface.
|
||||||
|
|
||||||
|
**Do not invent restrictions.** Do not reject, prohibit, constrain, cap,
|
||||||
|
harden, or otherwise narrow behavior unless required by the user, an explicit
|
||||||
|
repository contract, or an unavoidable correctness constraint. Preserve
|
||||||
|
operator and caller choice where policy has not been established. If an
|
||||||
|
additional restriction appears advisable, present it as a decision rather than
|
||||||
|
silently implementing it.
|
||||||
|
|
||||||
|
A correctness constraint must follow from the requested behavior or an
|
||||||
|
established invariant, not from the convenience of the chosen implementation.
|
||||||
|
Explain the specific failure it prevents and why a less restrictive
|
||||||
|
implementation cannot satisfy the contract.
|
||||||
|
|
||||||
|
**Do not invent guarantees.** Do not strengthen public contracts, guarantees,
|
||||||
|
or invariants beyond those expressly requested by the user.
|
||||||
|
|
||||||
|
**Use the standard library.** For example: in almost every case, the best
|
||||||
|
approach to copying files is to use the standard library. Writing custom code to
|
||||||
|
implement power-loss protection at the application level, or to guard against
|
||||||
|
bad actors with privileged and concurrent access to the host's filesystem,
|
||||||
|
is wasteful, unnecessary, and not idiomatic. Stick to the simple, standard, and
|
||||||
|
idiomatic approach unless another path is expressly requested by the user.
|
||||||
|
|
||||||
|
**An implementation detail is not repository policy.** Tests and documentation
|
||||||
|
must not elevate incidental behavior into a durable guarantee or restriction
|
||||||
|
unless that behavior was expressly selected as part of the contract. Existing
|
||||||
|
explicit contracts remain authoritative; changing or weakening one requires a
|
||||||
|
deliberate decision rather than being inferred from this principle.
|
||||||
|
|
||||||
|
Agent-authored plans, tests, documentation, and prior implementations are not
|
||||||
|
evidence that an additional restriction was deliberately approved. Identify the
|
||||||
|
authorizing requirement or decision; when that authority is unclear, surface
|
||||||
|
the ambiguity rather than treating repetition as approval.
|
||||||
|
|
||||||
|
## Git Commit Policy
|
||||||
|
|
||||||
|
By default, create a commit after completing each coherent unit of work, using a
|
||||||
|
concise, plain-English message describing what changed. This policy is standing
|
||||||
|
authorization to create those local commits; a separate user request is not
|
||||||
|
required. An explicit user instruction to leave work uncommitted takes precedence.
|
||||||
|
|
||||||
|
A unit of work is a focused, reviewable outcome, including its relevant tests and
|
||||||
|
documentation. Do not commit after every intermediate edit or create empty commits.
|
||||||
|
Complete the appropriate validation before committing. If work is incomplete or
|
||||||
|
validation is blocked, report that state rather than presenting it as completed.
|
||||||
|
|
||||||
|
Before committing:
|
||||||
|
|
||||||
|
1. Run `git status --short` and review the relevant diff, including any staged
|
||||||
|
changes. Identify changes that predate the current unit of work.
|
||||||
|
2. Exclude secrets, local environment files, generated junk, and unrelated changes.
|
||||||
|
3. Stage only the files or hunks belonging to the completed unit. Use `git add .`
|
||||||
|
only when every included change belongs to that unit. Do not disturb unrelated
|
||||||
|
staged or unstaged work.
|
||||||
|
4. Review `git diff --staged` and run appropriate checks for the change. Do not
|
||||||
|
repeat checks already passed for unchanged content merely to create a commit.
|
||||||
|
5. Review `git log --oneline -n 10` and use
|
||||||
|
`git commit -m "<concise plain-English commit message>"`.
|
||||||
|
6. Report the commit identifier and any remaining uncommitted work.
|
||||||
|
|
||||||
|
If the unit cannot be safely separated from pre-existing work, leave it uncommitted
|
||||||
|
and explain the overlap rather than silently including that work. If a commit
|
||||||
|
fails, preserve the changes and report the failure; do not bypass hooks.
|
||||||
|
|
||||||
|
Default commit authorization does not authorize pushing, publishing, amending,
|
||||||
|
resetting, reverting, or rewriting history. Those actions require a separate user
|
||||||
|
instruction. Do not use `git commit -a` or `git commit --no-verify` to bypass the
|
||||||
|
staging and validation discipline above.
|
||||||
@@ -10,7 +10,7 @@ ConfigKit is an opinionated Go library for consistent YAML configuration loading
|
|||||||
and resolution across consuming applications. This policy governs contributions.
|
and resolution across consuming applications. This policy governs contributions.
|
||||||
The [component overview](../internal/overview.md) owns implementation inventory, the
|
The [component overview](../internal/overview.md) owns implementation inventory, the
|
||||||
[configuration contract](../config.md) owns implemented behavior, and the
|
[configuration contract](../config.md) owns implemented behavior, and the
|
||||||
[initial target record](../roadmap/initial.md) preserves deferred scope.
|
[deferred scope](../roadmap/deferred.md) preserves deferred scope.
|
||||||
|
|
||||||
## Success criteria
|
## Success criteria
|
||||||
|
|
||||||
@@ -71,11 +71,23 @@ network provider into the library.
|
|||||||
## Public API and dependency direction
|
## Public API and dependency direction
|
||||||
|
|
||||||
Keep one small, coherent public surface for the supported lifecycle. Simple
|
Keep one small, coherent public surface for the supported lifecycle. Simple
|
||||||
single-document consumers must not need import, profile, or provider machinery.
|
single-document consumers must not need import, overlay, or provider machinery.
|
||||||
Do not expose parser-specific nodes, errors, or extension hooks through the
|
Do not expose parser-specific nodes, errors, or extension hooks through the
|
||||||
public API. Parser integration belongs behind a private boundary, as recorded
|
public API. Parser integration belongs behind a private boundary, as recorded
|
||||||
in [ADR 0002](../adr/0002-own-yaml-semantics.md).
|
in [ADR 0002](../adr/0002-own-yaml-semantics.md).
|
||||||
|
|
||||||
|
The root `configkit` package is the sole supported consumer import path. Public
|
||||||
|
named types stay root-owned and may hold private delegated implementation state;
|
||||||
|
their exported signatures, fields, and method sets must not expose internal
|
||||||
|
types or acquire internal methods through anonymous embedding. Root production
|
||||||
|
code may depend inward on a cohesive engine and its supporting internal packages,
|
||||||
|
but internal production packages must not import the root. Explicit forwarding
|
||||||
|
and local conversion are acceptable where they preserve type identity,
|
||||||
|
ownership, callback lifetime, and inspection behavior. The accepted root-wrapper
|
||||||
|
boundary and its migration rationale are recorded in
|
||||||
|
[ADR 0006](../adr/0006-public-facade.md); the implemented layout is described in
|
||||||
|
the [component overview](../internal/overview.md).
|
||||||
|
|
||||||
Separate input access, document processing, composition, decoding, metadata, and
|
Separate input access, document processing, composition, decoding, metadata, and
|
||||||
rendering responsibilities without prescribing an interface or package for each.
|
rendering responsibilities without prescribing an interface or package for each.
|
||||||
Dependencies must preserve those boundaries; package splits and abstractions
|
Dependencies must preserve those boundaries; package splits and abstractions
|
||||||
@@ -94,7 +106,7 @@ same inputs and options. Invalid input must not produce a successful usable
|
|||||||
configuration. Do not run application hooks on invalid typed configuration.
|
configuration. Do not run application hooks on invalid typed configuration.
|
||||||
|
|
||||||
User-controlled precedence is application defaults, composed base, selected
|
User-controlled precedence is application defaults, composed base, selected
|
||||||
profile, then explicitly supplied CLI overrides. Applications own flag parsing;
|
overlay, then explicitly supplied CLI overrides. Applications own flag parsing;
|
||||||
ConfigKit owns overlay semantics and provenance. Domain resolution follows these
|
ConfigKit owns overlay semantics and provenance. Domain resolution follows these
|
||||||
inputs and must remain recorded, not become a hidden override channel.
|
inputs and must remain recorded, not become a hidden override channel.
|
||||||
|
|
||||||
@@ -136,7 +148,7 @@ for safe messages supplied by their hooks.
|
|||||||
Do not introduce automatic directory discovery, network providers, arbitrary
|
Do not introduce automatic directory discovery, network providers, arbitrary
|
||||||
merge strategies, additional configuration languages, or application runtime
|
merge strategies, additional configuration languages, or application runtime
|
||||||
management without concrete consumer evidence and an explicit design decision.
|
management without concrete consumer evidence and an explicit design decision.
|
||||||
The [initial target record](../roadmap/initial.md#explicit-non-goals-and-deferred-scope)
|
The [deferred scope](../roadmap/deferred.md#explicit-non-goals-and-deferred-scope)
|
||||||
owns the deferred-feature inventory. Significant boundary
|
owns the deferred-feature inventory. Significant boundary
|
||||||
changes require an ADR and corresponding policy/contract updates; implementation
|
changes require an ADR and corresponding policy/contract updates; implementation
|
||||||
convenience alone is not a reason to broaden the public API.
|
convenience alone is not a reason to broaden the public API.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Documentation Policy
|
# Documentation Policy
|
||||||
|
|
||||||
**Base Policy Version:** 1.0
|
**Base Policy Version:** 1.1
|
||||||
|
|
||||||
**Last Updated:** 2026-09-06
|
**Last Updated:** 2026-09-23
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
@@ -28,8 +28,8 @@ semantics, defaults, paths, diagnostics, or compatibility guarantees.
|
|||||||
implementation; execution status belongs in roadmaps.
|
implementation; execution status belongs in roadmaps.
|
||||||
- Roadmaps own unimplemented targets, deferred work, and execution status.
|
- Roadmaps own unimplemented targets, deferred work, and execution status.
|
||||||
|
|
||||||
The initial target and implementation plan have separate owners. Do not put
|
Future implementation plans own their task order, acceptance gates, and progress;
|
||||||
staged steps or progress in `docs/roadmap/initial.md`. `docs/roadmap/implementation.md` owns those details.
|
`docs/roadmap/deferred.md` owns deliberate exclusions, not execution instructions.
|
||||||
As behavior lands, move its normative definition into the current contract and
|
As behavior lands, move its normative definition into the current contract and
|
||||||
replace the roadmap definition with a link. Retire completed roadmap material
|
replace the roadmap definition with a link. Retire completed roadmap material
|
||||||
as part of release preparation; ADRs preserve significant decision history.
|
as part of release preparation; ADRs preserve significant decision history.
|
||||||
@@ -56,22 +56,22 @@ handling, not secret values.
|
|||||||
|
|
||||||
| Topic | Owner | Boundary |
|
| Topic | Owner | Boundary |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Product orientation | `README.md` | Current status, purpose, navigation, and a minimal working example once available |
|
| Product orientation | `README.md` | Current status, purpose, navigation, and a minimal working example |
|
||||||
| Contributor entry point | `docs/development.md` | Reading guide and actual baseline validation commands |
|
| Contributor entry point | `docs/development.md` | Reading guide and actual baseline validation commands |
|
||||||
| Architecture rules | `docs/policy/architecture.md` | Responsibilities, dependency constraints, invariants, and scope boundaries |
|
| Architecture rules | `docs/policy/architecture.md` | Responsibilities, dependency constraints, invariants, and scope boundaries |
|
||||||
|
| Agent rules | `docs/policy/agents.md` | Implementation discipline, proportionality, rules against inventing contracts, and local commit authorization; not application contracts or architecture |
|
||||||
| Documentation rules | `docs/policy/documentation.md` | Ownership and document lifecycle |
|
| Documentation rules | `docs/policy/documentation.md` | Ownership and document lifecycle |
|
||||||
| Testing rules | `docs/policy/testing.md` | Risk-based sufficiency, test boundaries, and maintenance principles |
|
| Testing rules | `docs/policy/testing.md` | Risk-based sufficiency, test boundaries, and maintenance principles |
|
||||||
| Release rules | `docs/policy/release.md` | Compatibility, integrity, gates, immutability, and exceptions |
|
| Release rules | `docs/policy/release.md` | Compatibility, integrity, gates, immutability, and exceptions |
|
||||||
| Release procedure | `docs/release.md` | Exact maintainer commands, prerequisites, supported targets, and verification |
|
| Release procedure | `docs/release.md` | Exact maintainer commands, prerequisites, supported targets, and verification |
|
||||||
| Exported Go API | Go documentation beside declarations | Symbol contracts, errors, ownership, and lifecycle API behavior |
|
| Exported Go API | Go documentation beside declarations | Symbol contracts, errors, ownership, and lifecycle API behavior |
|
||||||
| YAML and resolution semantics | `docs/config.md`, as behavior lands | Syntax, composition, precedence, conversion, secret references/bootstrap, and explanation semantics; Go reference docs link here for shared rules |
|
| YAML and resolution semantics | `docs/config.md` | Syntax, composition, precedence, conversion, secret references/bootstrap, and explanation semantics; Go reference docs link here for shared rules |
|
||||||
| Consumer walkthroughs | `docs/consumers/`, when useful | Task-oriented usage and consumer responsibilities, not duplicate API reference |
|
| Consumer walkthroughs | `docs/consumers/`, when useful | Task-oriented usage and consumer responsibilities, not duplicate API reference |
|
||||||
| Component inventory | `docs/internal/overview.md` | Implemented components and links to their owners |
|
| Component inventory | `docs/internal/overview.md` | Implemented components and links to their owners |
|
||||||
| Component mechanics | Other `docs/internal/` files, when needed | Detailed implementation behavior and change recipes |
|
| Component mechanics | Other `docs/internal/` files, when needed | Detailed implementation behavior and change recipes |
|
||||||
| Architectural decisions | `docs/adr/` | Context, decisions, alternatives, consequences, and supersession history |
|
| Architectural decisions | `docs/adr/` | Context, decisions, alternatives, consequences, and supersession history |
|
||||||
| Initial target record | `docs/roadmap/initial.md` | Implemented-feature owner links and deferred feature scope until roadmap retirement |
|
| Deferred scope | `docs/roadmap/deferred.md` | Deliberate exclusions and the conditions for reconsidering them |
|
||||||
| Implementation plan | `docs/roadmap/implementation.md` | Stages, dependencies, acceptance gates, and progress |
|
| Future implementation plans | Task-specific files under `docs/roadmap/` | Unfinished work, task order, acceptance gates, and progress; completed plans retire to Git history |
|
||||||
| Release readiness evidence | `docs/roadmap/release-readiness.md` | Closed preparation gaps and outstanding candidate/publication evidence until roadmap retirement |
|
|
||||||
| Historical release notes | `docs/releases/` | Version-specific historical summaries required by release policy |
|
| Historical release notes | `docs/releases/` | Version-specific historical summaries required by release policy |
|
||||||
| Complete examples | `examples/` and executable Go examples | Maintained artifacts; prose links instead of duplicating full examples |
|
| Complete examples | `examples/` and executable Go examples | Maintained artifacts; prose links instead of duplicating full examples |
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Release Policy
|
# Release Policy
|
||||||
|
|
||||||
**Base Policy Version:** 0.1.0
|
**Base Policy Version:** 1.0
|
||||||
|
|
||||||
**Last Updated:** 2026-09-06
|
**Last Updated:** 2026-09-23
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
@@ -21,7 +21,8 @@ ConfigKit adopts a source-only Go module release model. The source tag and
|
|||||||
version-matched release note are the declared deliverables. Standalone binaries,
|
version-matched release note are the declared deliverables. Standalone binaries,
|
||||||
container images, and separate binary/package-registry publication are not
|
container images, and separate binary/package-registry publication are not
|
||||||
required. This does not waive source compilation, module hygiene, testing, or
|
required. This does not waive source compilation, module hygiene, testing, or
|
||||||
consumer-facing module retrieval and build verification.
|
local target-build validation. Post-publication consumer retrieval is optional,
|
||||||
|
as documented under Policy Exceptions.
|
||||||
|
|
||||||
The release procedure must define the module path, authoritative remote,
|
The release procedure must define the module path, authoritative remote,
|
||||||
supported Go versions and operating-system/architecture targets, prerequisite
|
supported Go versions and operating-system/architecture targets, prerequisite
|
||||||
@@ -35,6 +36,20 @@ can still break consumers. Human diagnostic wording is stable only where a
|
|||||||
contract expressly promises it. Minimum Go version and platform support changes
|
contract expressly promises it. Minimum Go version and platform support changes
|
||||||
also require an explicit compatibility assessment.
|
also require an explicit compatibility assessment.
|
||||||
|
|
||||||
|
### Platform support
|
||||||
|
|
||||||
|
Linux is the release-validated platform. macOS has best-effort support: keeping
|
||||||
|
it working remains desirable, but neither native macOS validation nor Darwin
|
||||||
|
cross-builds are release gates. Missing macOS evidence or a macOS-specific failure
|
||||||
|
does not by itself block a release. Voluntary checks and fixes are welcome; there
|
||||||
|
is no guarantee that each release has been validated on macOS.
|
||||||
|
|
||||||
|
The [release procedure](../release.md#release-identity-and-support) owns target
|
||||||
|
architectures and required Linux checks. This support change applies to upcoming
|
||||||
|
releases; historical release notes and their validation records remain unchanged.
|
||||||
|
The v0.3.0 release note must explain the change in Compatibility, with version
|
||||||
|
selection following this policy's existing compatibility rules.
|
||||||
|
|
||||||
## Release Model
|
## Release Model
|
||||||
|
|
||||||
A release consists of:
|
A release consists of:
|
||||||
@@ -60,6 +75,10 @@ responsibility is documented. Automation must not silently change the source
|
|||||||
candidate, move a published tag, replace a published deliverable, or repair a
|
candidate, move a published tag, replace a published deliverable, or repair a
|
||||||
release in place.
|
release in place.
|
||||||
|
|
||||||
|
A successful push of the intended annotated tag completes the source-only release.
|
||||||
|
Never wait for CI or use its status as a readiness or completion gate. Optional
|
||||||
|
post-publication checks must not delay reporting success.
|
||||||
|
|
||||||
## Versioning And Compatibility
|
## Versioning And Compatibility
|
||||||
|
|
||||||
Stable releases use Semantic Versioning in the form `vMAJOR.MINOR.PATCH`, with
|
Stable releases use Semantic Versioning in the form `vMAJOR.MINOR.PATCH`, with
|
||||||
@@ -118,38 +137,28 @@ current documentation or defer required documentation changes.
|
|||||||
|
|
||||||
## Candidate Preparation And Validation
|
## Candidate Preparation And Validation
|
||||||
|
|
||||||
Each repository must provide one documented release-check command or script
|
ConfigKit provides one local release checker. Run it once against the final
|
||||||
that exercises the complete set of automated source-candidate gates. The
|
committed candidate using one maintained patch of the minimum supported Go line
|
||||||
repository-specific procedure must state all additional manual gates.
|
on Linux. It checks module hygiene, formatting and whitespace, the full offline
|
||||||
|
suite with the race detector, static analysis, and CGO-disabled Linux amd64/arm64
|
||||||
|
builds. Examples, documentation links, consumer fixtures, and package boundaries
|
||||||
|
are covered by that suite, not separate repeated runs. Native package compilation
|
||||||
|
is covered by the test run. A second toolchain is optional and non-gating.
|
||||||
|
|
||||||
The required gates are:
|
Commit the release note and associated changes before release validation. Record
|
||||||
|
the exact validated commit; revalidate if the candidate changes. Do not require
|
||||||
|
both pre-commit and post-commit full checker runs for unchanged content.
|
||||||
|
Do not publish uncommitted or unvalidated content. Local check failures still
|
||||||
|
block publication. Skipped test cases are reported, not separate human sign-off
|
||||||
|
requirements.
|
||||||
|
|
||||||
- dependency or module hygiene appropriate to the language and ecosystem;
|
Compatibility review, release-note accuracy, removal of completed roadmaps, and
|
||||||
- the complete deterministic, offline default test suite;
|
review for unintended files, credentials, private data, and unrelated changes
|
||||||
- race, concurrency, or equivalent safety checks where the implementation can
|
are ordinary preparation. The executing agent may perform them without routine
|
||||||
be meaningfully checked for those risks;
|
human approval or separate manual evidence records. Ask the maintainer only for
|
||||||
- static analysis and linting used by the repository;
|
genuine unresolved decisions or missing authority. A clean existing checkout is
|
||||||
- production builds for every supported target that can be built in the
|
sufficient; a dedicated clone is optional. Dependency and toolchain update
|
||||||
release environment;
|
research belongs to maintenance or relevant changes, not every release.
|
||||||
- formatting and whitespace checks;
|
|
||||||
- validation of maintained examples, configuration, schemas, generated
|
|
||||||
sources, and documentation commands where applicable;
|
|
||||||
- review of changed local documentation links;
|
|
||||||
- removal of obsolete roadmap documents under `docs/roadmap/` that describe
|
|
||||||
completed work included in the release;
|
|
||||||
- review for unintended files, generated output, credentials, private data,
|
|
||||||
and unrelated changes; and
|
|
||||||
- any additional security, compatibility, migration, packaging, or platform
|
|
||||||
checks required by the product.
|
|
||||||
|
|
||||||
Unavailable tooling or an inapplicable check must be addressed explicitly in
|
|
||||||
the adopted policy or release procedure; it must not disappear silently from
|
|
||||||
the candidate gates.
|
|
||||||
|
|
||||||
Run the complete checker while preparing the candidate. Commit the release
|
|
||||||
note and all associated code, tests, documentation, and configuration, then
|
|
||||||
run the complete checker again against the exact commit intended for release.
|
|
||||||
Do not publish a release from uncommitted or unvalidated content.
|
|
||||||
|
|
||||||
## Publication Guards
|
## Publication Guards
|
||||||
|
|
||||||
@@ -164,7 +173,8 @@ verify all of the following:
|
|||||||
- the authoritative remote branch points to that same commit;
|
- the authoritative remote branch points to that same commit;
|
||||||
- the matching release note exists in that commit and has the required title
|
- the matching release note exists in that commit and has the required title
|
||||||
and sections;
|
and sections;
|
||||||
- all required approvals and automated gates succeeded for that commit; and
|
- release publication is authorized and the local automated gates succeeded for
|
||||||
|
that commit; and
|
||||||
- no local or remote tag already uses the selected version.
|
- no local or remote tag already uses the selected version.
|
||||||
|
|
||||||
Create the version tag against the guarded commit and verify its target before
|
Create the version tag against the guarded commit and verify its target before
|
||||||
@@ -205,38 +215,48 @@ order:
|
|||||||
1. maintainer prerequisites and required access;
|
1. maintainer prerequisites and required access;
|
||||||
2. version selection and compatibility review;
|
2. version selection and compatibility review;
|
||||||
3. release-note creation;
|
3. release-note creation;
|
||||||
4. candidate preparation and the complete validation command;
|
4. candidate preparation and commit;
|
||||||
5. candidate commit, push, and exact commit recording;
|
5. one local validation pass, exact candidate recording, and push;
|
||||||
6. publication guards;
|
6. publication guards;
|
||||||
7. exact tag and deliverable publication commands;
|
7. exact tag and deliverable publication commands;
|
||||||
8. authoritative remote and consumer-facing verification; and
|
8. completion on successful tag push and optional post-publication checks; and
|
||||||
9. failure handling and correction.
|
9. failure handling and correction.
|
||||||
|
|
||||||
The procedure should use copyable, fail-fast commands where practical. It must
|
The procedure should use copyable, fail-fast commands where practical. It must
|
||||||
name the primary branch, authoritative remote, tag form, published
|
name the primary branch, authoritative remote, tag form, published
|
||||||
deliverables, supported build targets, release automation responsibilities,
|
deliverables, supported build targets, release automation responsibilities,
|
||||||
and consumer verification path. It must link to this policy instead of
|
and optional consumer verification path. It must link to this policy instead of
|
||||||
restating or redefining its requirements.
|
restating or redefining its requirements.
|
||||||
|
|
||||||
## Policy Exceptions
|
## Policy Exceptions
|
||||||
|
|
||||||
### Temporary bootstrap deferral
|
Any exception to this policy must be explicitly documented in this section,
|
||||||
|
including the repository constraint requiring it, its rationale, scope, and
|
||||||
|
compensating controls. Customizing release mechanics must not silently weaken
|
||||||
|
the policy's integrity, validation, documentation, verification, or immutability
|
||||||
|
requirements.
|
||||||
|
|
||||||
The release procedure and automated source-candidate checker now exist, but no exact
|
For ConfigKit's source-only releases, the maintainer accepts successful publication
|
||||||
release candidate, version-matched release note, native macOS execution evidence, or
|
of the intended tag as sufficient completion evidence. This is an explicit
|
||||||
post-publication module retrieval has been validated. Initial implementation may be
|
exception to the shared standard's mandatory post-publication verification:
|
||||||
completed without fabricating those later facts. This exception does not authorize
|
authoritative tag read-back and fresh consumer retrieval are optional and
|
||||||
publication or waive any candidate, platform, remote, or consumer-verification gate.
|
non-gating. CI status and downstream retrieval availability must not delay release
|
||||||
|
completion. Compensating controls are local validation of the exact candidate,
|
||||||
|
automated pre-publication guards, matching remote main, verification of the local
|
||||||
|
annotated tag target, explicit ref pushes, and immutable published versions.
|
||||||
|
|
||||||
Compensating controls: the [release procedure](../release.md) keeps every unvalidated
|
For v0.1.1 only, the maintainer approved renaming the reserved `_configkit` key to
|
||||||
step as an explicit maintainer gate, and the
|
`configkit` as an error-correcting point release because there are no current
|
||||||
[release-readiness evidence](../roadmap/release-readiness.md) records what is closed
|
consumers. This intentionally changes the v0.1.0 configuration contract without a
|
||||||
and outstanding. No release may be published until an exact committed candidate has
|
minor-version increment. The release note must explain the renamed directive,
|
||||||
the required release note, passes all applicable automated and native-platform gates,
|
the lack of a legacy alias, and the newly reserved application key. This exception
|
||||||
is verified against `origin/main`, and completes guarded tag publication and clean
|
does not apply to subsequent releases or waive any validation or publication gate.
|
||||||
consumer retrieval. The maintainer preparing the first release owns closure. Remove
|
|
||||||
this temporary exception when that evidence is recorded and obsolete roadmap files
|
For v0.2.0 only, the maintainer accepted the successful native macOS check at
|
||||||
are retired. Any other exception requires a documented repository constraint, rationale,
|
`244104f` for the final candidate, provided subsequent changes are documentation
|
||||||
compensating controls, scope, and closure condition here; none is currently
|
only. The final candidate still requires the complete Linux release checker.
|
||||||
adopted. Do not silently weaken the base integrity, validation, documentation,
|
This exception waives only repeating the macOS check after those documentation
|
||||||
verification, or immutability guarantees.
|
changes; it does not waive other publication or verification gates.
|
||||||
|
|
||||||
|
Candidate selection and guarded publication remain mandatory; post-publication
|
||||||
|
verification is optional in the [release procedure](../release.md).
|
||||||
|
|||||||
176
docs/release.md
176
docs/release.md
@@ -12,18 +12,24 @@ binary, container image, generated package, or separate registry publication.
|
|||||||
- Authoritative remote: `origin`, expected to fetch from
|
- Authoritative remote: `origin`, expected to fetch from
|
||||||
`https://gitea.maximumdirect.net/eric/ConfigKit.git`
|
`https://gitea.maximumdirect.net/eric/ConfigKit.git`
|
||||||
- Tags: stable Semantic Versions such as `v0.1.0`; prerelease tags are not supported
|
- Tags: stable Semantic Versions such as `v0.1.0`; prerelease tags are not supported
|
||||||
- Minimum Go language/toolchain line: Go 1.25, tested with a supported patched
|
- Minimum Go language/toolchain line: Go 1.25, release-validated with one maintained
|
||||||
Go 1.25 toolchain and one newer supported toolchain
|
patch of that line; newer-toolchain testing is optional and non-gating
|
||||||
- Build targets: Linux and macOS on amd64 and arm64
|
- Release-gated build targets: Linux on amd64 and arm64
|
||||||
|
- Best-effort targets: macOS on amd64 and arm64, under the
|
||||||
|
[platform support policy](policy/release.md#platform-support)
|
||||||
|
|
||||||
The automated checker cross-builds all four targets. Before release, the maintainer
|
The local Linux checker includes native race tests and CGO-disabled cross-builds.
|
||||||
must also obtain native test, race, symlink, and permission-fixture evidence on Linux
|
Its output reports skipped filesystem cases; no separate evidence record or
|
||||||
and macOS; cross-compilation is not native execution evidence.
|
human sign-off is required. Native macOS checks and Darwin cross-builds are
|
||||||
|
optional and do not gate publication.
|
||||||
|
|
||||||
|
CI is never a release gate. Do not wait for it, poll its status, or delay reporting
|
||||||
|
completion because it is pending, unavailable, or failed.
|
||||||
|
|
||||||
## 1. Prepare access and tools
|
## 1. Prepare access and tools
|
||||||
|
|
||||||
The release maintainer owns this procedure. Work in a dedicated clone with Git, Bash 3.2 or newer,
|
The release maintainer owns this procedure. Use a clean checkout (a dedicated clone is optional) with Git, Bash 3.2 or newer,
|
||||||
and two supported Go toolchains available. The checker supports the stock macOS
|
a maintained Go 1.25 patch, and the compiler needed for native race tests. The checker supports the stock macOS
|
||||||
Bash; its filename enumeration is null-delimited, including names with whitespace. Configure private-host Git and Go module
|
Bash; its filename enumeration is null-delimited, including names with whitespace. Configure private-host Git and Go module
|
||||||
access through the normal credential manager before starting. Keep tokens, passwords,
|
access through the normal credential manager before starting. Keep tokens, passwords,
|
||||||
SSH material, and environment dumps outside the repository and command transcript.
|
SSH material, and environment dumps outside the repository and command transcript.
|
||||||
@@ -34,23 +40,24 @@ git remote get-url origin
|
|||||||
git ls-remote origin HEAD
|
git ls-remote origin HEAD
|
||||||
```
|
```
|
||||||
|
|
||||||
The remote URL must equal the authoritative URL above. Select the latest supported
|
The remote URL must equal the authoritative URL above. Acquire the selected
|
||||||
patch in the Go 1.25 line and a newer supported toolchain. The values below are the
|
toolchain and dependencies explicitly. For example, with the maintained patch
|
||||||
toolchains used while this runbook was introduced; update them to supported patch
|
used when this procedure was simplified:
|
||||||
releases during candidate preparation and record the actual versions in the release
|
|
||||||
note or validation record.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export CONFIGKIT_BASELINE_TOOLCHAIN=go1.25.5
|
export CONFIGKIT_BASELINE_TOOLCHAIN=go1.25.14
|
||||||
export CONFIGKIT_NEWER_TOOLCHAIN=go1.26.7
|
|
||||||
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go version
|
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go version
|
||||||
GOTOOLCHAIN="$CONFIGKIT_NEWER_TOOLCHAIN" go version
|
|
||||||
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go mod download
|
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go mod download
|
||||||
GOTOOLCHAIN="$CONFIGKIT_NEWER_TOOLCHAIN" go mod download
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Those commands explicitly acquire toolchains and dependencies. The checker itself
|
Keep toolchains maintained, but do not require latest-version discovery for each
|
||||||
sets `GOPROXY=off` and fails if its required cache is incomplete.
|
release. The checker sets `GOPROXY=off` and fails if the cache is incomplete.
|
||||||
|
Toolchain and YAML-dependency update research belongs to periodic maintenance or
|
||||||
|
relevant dependency changes. No renewed approval of an unchanged pin is required.
|
||||||
|
|
||||||
|
Use Bash with `set -euo pipefail` for the command blocks below so any failed gate
|
||||||
|
stops the sequence. Publication requires user authorization; the agent policy's
|
||||||
|
local-commit authorization is not permission to push or release.
|
||||||
|
|
||||||
## 2. Select the version and review compatibility
|
## 2. Select the version and review compatibility
|
||||||
|
|
||||||
@@ -60,7 +67,7 @@ For the first release, review the whole public surface and current contracts.
|
|||||||
```sh
|
```sh
|
||||||
release_version=v0.1.0
|
release_version=v0.1.0
|
||||||
printf '%s\n' "$release_version" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
|
printf '%s\n' "$release_version" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
|
||||||
git show-ref --verify --quiet "refs/tags/$release_version" && exit 1 || :
|
if git show-ref --verify --quiet "refs/tags/$release_version"; then exit 1; fi
|
||||||
if git ls-remote --exit-code --tags origin "refs/tags/$release_version" >/dev/null; then
|
if git ls-remote --exit-code --tags origin "refs/tags/$release_version" >/dev/null; then
|
||||||
printf 'remote tag already exists: %s\n' "$release_version" >&2
|
printf 'remote tag already exists: %s\n' "$release_version" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -76,16 +83,9 @@ intentional incompatible minor-release change must be explained with consumer ac
|
|||||||
patch releases must remain compatible within their minor line. Review the maintained
|
patch releases must remain compatible within their minor line. Review the maintained
|
||||||
consumer examples and the migration guide even when signatures did not change.
|
consumer examples and the migration guide even when signatures did not change.
|
||||||
|
|
||||||
Reassess `go.yaml.in/yaml/v4` rather than using `@latest` implicitly:
|
When dependencies change, review parser behavior and migration impact as part of
|
||||||
|
that change. Keep Compatibility accurate about the dependency pin, but do not
|
||||||
```sh
|
perform an external version survey for every patch release.
|
||||||
go list -m -versions go.yaml.in/yaml/v4
|
|
||||||
go list -m -json go.yaml.in/yaml/v4@latest
|
|
||||||
```
|
|
||||||
|
|
||||||
If changing the explicit pin, make that a reviewed dependency change and rerun the
|
|
||||||
complete checker, especially parser policy, limits, diagnostics, and rendering tests.
|
|
||||||
If retaining a prerelease pin, record that decision in Compatibility.
|
|
||||||
|
|
||||||
## 3. Write the release note
|
## 3. Write the release note
|
||||||
|
|
||||||
@@ -97,55 +97,46 @@ release note until there is a real candidate and reviewed content.
|
|||||||
|
|
||||||
## 4. Validate the source candidate
|
## 4. Validate the source candidate
|
||||||
|
|
||||||
Run the complete checker from the repository root after dependencies have been
|
Before release validation, finish compatibility review, inspect the diff and file
|
||||||
acquired. It validates module manifests in a disposable copy, both supported Go
|
list for unintended content or credentials, retire completed roadmaps, repair
|
||||||
toolchains, offline default tests, the native race detector, vet, native builds,
|
links, and review the release note. These are ordinary preparation tasks the
|
||||||
CGO-disabled cross-builds, formatting and whitespace (including untracked files),
|
executing agent may perform, not separate human approvals or evidence records.
|
||||||
examples, package isolation, and local documentation links.
|
Ask the maintainer only for an unresolved product decision or missing authority.
|
||||||
|
|
||||||
|
Commit the candidate, staging only its intended changes, then run the checker
|
||||||
|
once against that clean commit. Do not run both everyday and release checkers to
|
||||||
|
repeat the same gates.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./scripts/release-check.sh
|
git status --short
|
||||||
```
|
# Stage the specific files belonging to the candidate, then review:
|
||||||
|
git diff --staged
|
||||||
`go vet` is the repository's static-analysis baseline. No additional linter is
|
|
||||||
claimed. Links to current ConfigKit files and headings are strict. Recorded links to
|
|
||||||
sibling consumer source are checked when those checkouts exist and are otherwise
|
|
||||||
inapplicable in an isolated clone; their immutable revisions remain recorded in the
|
|
||||||
extraction inventory.
|
|
||||||
|
|
||||||
Complete these manual gates and record their evidence with the candidate:
|
|
||||||
|
|
||||||
- The maintainer reviews compatibility and downstream migration impact against
|
|
||||||
`docs/config.md`, Go API comments, and the consumer guide.
|
|
||||||
- The maintainer inspects `git status --short`, `git diff`, and the candidate file
|
|
||||||
list for credentials, private data, generated output, unintended files, and
|
|
||||||
unrelated changes.
|
|
||||||
- The maintainer records successful native checker runs on Linux and macOS, including
|
|
||||||
filesystem capability tests. If either host is unavailable, release remains
|
|
||||||
blocked unless platform support is formally narrowed in current documentation.
|
|
||||||
- The maintainer removes completed, obsolete documents under `docs/roadmap/`, repairs
|
|
||||||
inbound links, and reruns the checker. ADR rationale remains historical.
|
|
||||||
- The maintainer reviews the exact version-matched release note for accuracy and
|
|
||||||
confirms it describes only deliverables that will actually be published.
|
|
||||||
|
|
||||||
## 5. Commit, push, and record the candidate
|
|
||||||
|
|
||||||
Commit all candidate source, contract, example, roadmap-retirement, and release-note
|
|
||||||
changes, rerun the checker against that clean commit, then push only `main` and record
|
|
||||||
the exact commit in Git metadata local to the clone:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git add .
|
|
||||||
git commit --message "Prepare ConfigKit $release_version"
|
git commit --message "Prepare ConfigKit $release_version"
|
||||||
./scripts/release-check.sh
|
|
||||||
test -z "$(git status --porcelain=v1 --untracked-files=all)"
|
test -z "$(git status --porcelain=v1 --untracked-files=all)"
|
||||||
git push origin HEAD:refs/heads/main
|
candidate_commit=$(git rev-parse HEAD)
|
||||||
git rev-parse HEAD | tee .git/configkit-release-candidate
|
./scripts/release-check.sh
|
||||||
candidate_commit=$(cat .git/configkit-release-candidate)
|
test "$(git rev-parse HEAD)" = "$candidate_commit"
|
||||||
|
test -z "$(git status --porcelain=v1 --untracked-files=all)"
|
||||||
|
printf '%s\n' "$candidate_commit" > "$(git rev-parse --git-path configkit-release-candidate)"
|
||||||
```
|
```
|
||||||
|
|
||||||
If review changes the source after this point, create and validate a new candidate;
|
If the final candidate is already committed, omit the staging and commit commands;
|
||||||
do not reuse the recorded commit.
|
do not create an empty commit. The checker validates module manifests in a
|
||||||
|
disposable copy, formatting, whitespace, the full offline suite once with the race
|
||||||
|
detector and verbose skip reporting, vet once, and CGO-disabled Linux amd64/arm64
|
||||||
|
builds. The suite includes examples, consumer fixtures, documentation links, and
|
||||||
|
package boundaries; no separate reruns are required. No additional linter is
|
||||||
|
claimed. Sibling-consumer links are checked when those checkouts exist and are
|
||||||
|
otherwise inapplicable in an isolated checkout.
|
||||||
|
|
||||||
|
A changed candidate requires validation again. Do not reuse a recorded candidate
|
||||||
|
from an older revision. The record is written only after successful local checks.
|
||||||
|
|
||||||
|
## 5. Prepare publication
|
||||||
|
|
||||||
|
The following guarded sequence pushes only `main` and the selected tag. Local
|
||||||
|
validation failures block it; CI does not. Keep the selected `release_version`
|
||||||
|
from step 2 in the same shell. If starting a new shell, set it explicitly again.
|
||||||
|
|
||||||
## 6. Run publication guards
|
## 6. Run publication guards
|
||||||
|
|
||||||
@@ -153,14 +144,12 @@ Immediately before tagging, fetch the authoritative branch and tags, restore the
|
|||||||
recorded variables if this is a new shell, and execute every guard:
|
recorded variables if this is a new shell, and execute every guard:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
release_version=v0.1.0
|
candidate_commit=$(cat "$(git rev-parse --git-path configkit-release-candidate)")
|
||||||
candidate_commit=$(cat .git/configkit-release-candidate)
|
|
||||||
git fetch --prune origin main
|
git fetch --prune origin main
|
||||||
git fetch --tags origin
|
git fetch --tags origin
|
||||||
test "$(git branch --show-current)" = main
|
test "$(git branch --show-current)" = main
|
||||||
test -z "$(git status --porcelain=v1 --untracked-files=all)"
|
test -z "$(git status --porcelain=v1 --untracked-files=all)"
|
||||||
test "$(git rev-parse HEAD)" = "$candidate_commit"
|
test "$(git rev-parse HEAD)" = "$candidate_commit"
|
||||||
test "$(git rev-parse refs/remotes/origin/main)" = "$candidate_commit"
|
|
||||||
test "$(git remote get-url origin)" = 'https://gitea.maximumdirect.net/eric/ConfigKit.git'
|
test "$(git remote get-url origin)" = 'https://gitea.maximumdirect.net/eric/ConfigKit.git'
|
||||||
printf '%s\n' "$release_version" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
|
printf '%s\n' "$release_version" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
|
||||||
test -f "docs/releases/$release_version.md"
|
test -f "docs/releases/$release_version.md"
|
||||||
@@ -168,17 +157,22 @@ test "$(sed -n '1p' "docs/releases/$release_version.md")" = "# ConfigKit $releas
|
|||||||
for heading in Summary Compatibility Upgrade Changes; do
|
for heading in Summary Compatibility Upgrade Changes; do
|
||||||
grep -Fxq "## $heading" "docs/releases/$release_version.md"
|
grep -Fxq "## $heading" "docs/releases/$release_version.md"
|
||||||
done
|
done
|
||||||
git show-ref --verify --quiet "refs/tags/$release_version" && exit 1 || :
|
if git show-ref --verify --quiet "refs/tags/$release_version"; then exit 1; fi
|
||||||
if git ls-remote --exit-code --tags origin "refs/tags/$release_version" >/dev/null; then
|
if git ls-remote --exit-code --tags origin "refs/tags/$release_version" >/dev/null; then
|
||||||
printf 'remote tag already exists: %s\n' "$release_version" >&2
|
printf 'remote tag already exists: %s\n' "$release_version" >&2
|
||||||
exit 1
|
exit 1
|
||||||
else
|
else
|
||||||
test $? -eq 2
|
test $? -eq 2
|
||||||
fi
|
fi
|
||||||
|
git push origin HEAD:refs/heads/main
|
||||||
|
git fetch origin main
|
||||||
|
test "$(git rev-parse refs/remotes/origin/main)" = "$candidate_commit"
|
||||||
|
test "$(git rev-parse HEAD)" = "$candidate_commit"
|
||||||
|
test -z "$(git status --porcelain=v1 --untracked-files=all)"
|
||||||
```
|
```
|
||||||
|
|
||||||
Confirm the recorded automated and manual approvals apply to exactly
|
The recorded local validation must apply to exactly `$candidate_commit`. No
|
||||||
`$candidate_commit`. A successful older run is not evidence for a changed commit.
|
separate human sign-off, CI status, or manual evidence document is required.
|
||||||
|
|
||||||
## 7. Tag and publish the exact ref
|
## 7. Tag and publish the exact ref
|
||||||
|
|
||||||
@@ -191,15 +185,17 @@ test "$(git rev-list -n 1 "$release_version")" = "$candidate_commit"
|
|||||||
git push origin "refs/tags/$release_version:refs/tags/$release_version"
|
git push origin "refs/tags/$release_version:refs/tags/$release_version"
|
||||||
```
|
```
|
||||||
|
|
||||||
The pushed tag completes the declared source-only publication. No Gitea release
|
A successful push of the intended tag completes the release. Report success now;
|
||||||
object or binary upload is required.
|
do not wait for CI, module propagation, or any post-publication check. No Gitea
|
||||||
|
release object or binary upload is required.
|
||||||
|
|
||||||
## 8. Verify publication and clean consumer retrieval
|
## 8. Optional post-publication checks
|
||||||
|
|
||||||
Verify the authoritative tag and then retrieve that exact version into a new module
|
Only when useful or requested, read back the remote tag or retrieve the exact
|
||||||
without a local `replace` directive. Private-host credentials must already be
|
version into a new module without a local `replace` directive. These checks are
|
||||||
configured. Use direct origin retrieval unless an explicitly approved internal Go
|
optional and non-gating; their failure or unavailability does not delay release
|
||||||
proxy is the publication channel.
|
completion. Do not poll CI or wait for module propagation. Private-host credentials
|
||||||
|
must already be configured. Example direct-origin verification:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
remote_tag_commit=$(git ls-remote origin "refs/tags/$release_version^{}" | awk '{print $1}')
|
remote_tag_commit=$(git ls-remote origin "refs/tags/$release_version^{}" | awk '{print $1}')
|
||||||
@@ -226,13 +222,13 @@ EOF
|
|||||||
rm -rf -- "$consumer_dir"
|
rm -rf -- "$consumer_dir"
|
||||||
```
|
```
|
||||||
|
|
||||||
The final `go list` output must show the exact module path and `$release_version`.
|
If performed, the final `go list` output should show the exact module path and
|
||||||
Record the result with the release evidence.
|
`$release_version`. Report optional verification separately from publication success.
|
||||||
|
|
||||||
## 9. Handle failures without replacement
|
## 9. Handle failures without replacement
|
||||||
|
|
||||||
Before publication, fix the candidate, rerun every gate, commit, push, and record a
|
Before publication, fix and commit the candidate, validate it, record the new
|
||||||
new exact candidate. An unpublished local tag may be deleted only after verifying it
|
exact candidate, and repeat the guarded publication sequence. An unpublished local tag may be deleted only after verifying it
|
||||||
was never pushed or used by another release channel.
|
was never pushed or used by another release channel.
|
||||||
|
|
||||||
After publication, never move, delete, or recreate the tag and never replace its
|
After publication, never move, delete, or recreate the tag and never replace its
|
||||||
|
|||||||
72
docs/releases/v0.1.0.md
Normal file
72
docs/releases/v0.1.0.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# ConfigKit v0.1.0
|
||||||
|
|
||||||
|
This is the first source release of ConfigKit, an opinionated Go library for strict,
|
||||||
|
explainable YAML configuration loading.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
ConfigKit provides one typed loading lifecycle for file, byte, reader, and
|
||||||
|
defaults-only inputs. It supports bounded explicit imports, strict composition,
|
||||||
|
selected profiles, explicit CLI overrides, application-owned defaults and recorded
|
||||||
|
resolution, semantic validation, provenance, redacted effective output, and safe
|
||||||
|
historical explanation.
|
||||||
|
|
||||||
|
Typed environment-name secret references and an explicit secrets-directory bootstrap
|
||||||
|
keep credential acquisition separate from ordinary loading. Maintained consumer
|
||||||
|
examples demonstrate simple and composed application shapes without exposing the
|
||||||
|
private YAML parser dependency.
|
||||||
|
|
||||||
|
See the [configuration contract](../config.md) and
|
||||||
|
[consumer adoption guide](../consumers/README.md) for current behavior and examples.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This is the initial public release, so there is no earlier ConfigKit release to
|
||||||
|
upgrade from. The module requires Go 1.25 or newer and supports Linux and macOS on
|
||||||
|
amd64 and arm64. Candidate validation uses patched Go 1.25 and Go 1.26 toolchains;
|
||||||
|
native Linux and macOS evidence is required in addition to cross-builds.
|
||||||
|
|
||||||
|
Configuration intentionally uses a strict YAML subset. Duplicate keys, aliases,
|
||||||
|
anchors, merge keys, unsupported tags and scalar forms, trailing documents, and
|
||||||
|
unknown typed fields are rejected. Composition rejects overlapping non-mapping
|
||||||
|
definitions; replacement occurs only through selected profiles, explicit CLI
|
||||||
|
overrides, or recorded application resolution.
|
||||||
|
|
||||||
|
The private parser dependency remains explicitly pinned to
|
||||||
|
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, which was still the newest available v4 release when
|
||||||
|
the candidate was prepared. No upstream YAML declarations are part of ConfigKit's
|
||||||
|
public API.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
No action is required for an earlier ConfigKit version because this is the first
|
||||||
|
release. New consumers can add the module with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The authoritative host may require its normal private-module credentials. Consumers
|
||||||
|
adapting existing application configuration should review the intentional migration
|
||||||
|
differences in the [consumer adoption guide](../consumers/README.md), particularly
|
||||||
|
strict YAML, string schema versions, explicit empty/null behavior, canonical module
|
||||||
|
objects, `int64` dynamic integers, explicit CLI records, and typed secret references.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Added opaque file, byte, reader, and defaults-only sources with fixed resource
|
||||||
|
limits and safe structured diagnostics.
|
||||||
|
- Added an owned strict YAML model, typed schema binding, detached defaults and
|
||||||
|
results, canonical projection, and private parser isolation.
|
||||||
|
- Added explicit path/glob imports, deterministic strict composition, canonical
|
||||||
|
source identity and containment, and selected profile overlays.
|
||||||
|
- Added layered presence, origins, chronological history, version checks, recorded
|
||||||
|
defaulting and normalization, and read-only semantic validation.
|
||||||
|
- Added secret classification, deterministic redacted effective YAML, typed
|
||||||
|
environment references, and bounded explicit secrets-directory bootstrap reports.
|
||||||
|
- Added parser-neutral explicit CLI overrides and safe structured/human historical
|
||||||
|
value explanations, including removed and superseded values.
|
||||||
|
- Added maintained Narratio-, Notarius-, WeatherReporter-, and Seriatim-shaped
|
||||||
|
examples and migration coverage using only ConfigKit and the standard library.
|
||||||
|
- Added race and bounded fuzz coverage plus a two-toolchain, offline, cross-platform
|
||||||
|
source-release checker and maintainer runbook.
|
||||||
43
docs/releases/v0.1.1.md
Normal file
43
docs/releases/v0.1.1.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# ConfigKit v0.1.1
|
||||||
|
|
||||||
|
This release corrects the spelling of ConfigKit's reserved configuration key.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The reserved directive key is now `configkit` instead of `_configkit`. Import
|
||||||
|
declarations continue to use the same `imports` sequence and path/glob syntax.
|
||||||
|
See the [configuration contract](../config.md#explicit-imports).
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This is an intentional configuration compatibility change, approved as a one-time
|
||||||
|
[patch-release exception](../policy/release.md#policy-exceptions) because there are
|
||||||
|
no current consumers. `_configkit` is no longer a directive or a reserved key; it
|
||||||
|
is ordinary application data and is subject to normal schema validation. There is
|
||||||
|
no legacy alias. `configkit` is now reserved, including in nested mappings, profiles,
|
||||||
|
and override paths and values.
|
||||||
|
|
||||||
|
The Go API, diagnostic codes, Go minimum version, and supported platforms are
|
||||||
|
unchanged. The private YAML dependency remains pinned to
|
||||||
|
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, confirmed as the newest available v4 release
|
||||||
|
during candidate preparation.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
Rename `_configkit` directive keys to `configkit` in root configuration files and
|
||||||
|
imported fragments. Rename any application-owned `configkit` fields to avoid the
|
||||||
|
newly reserved key. Update diagnostic-path expectations to the new spelling.
|
||||||
|
Files retaining `_configkit` will not load their declared imports.
|
||||||
|
|
||||||
|
Consumers can select this release with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.1.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Renamed the directive key consistently across imports, profiles, and overrides.
|
||||||
|
- Updated current configuration documentation and the composed consumer fixture.
|
||||||
|
- Updated behavioral tests and added coverage proving the old spelling is not an
|
||||||
|
import alias.
|
||||||
50
docs/releases/v0.1.2.md
Normal file
50
docs/releases/v0.1.2.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# ConfigKit v0.1.2
|
||||||
|
|
||||||
|
This release adds safe enumeration of effective and removed metadata paths.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`Metadata.HistoricalPaths` returns the semantic paths observed during a load,
|
||||||
|
including paths removed by profiles, CLI overrides, or recorded resolution. This
|
||||||
|
allows applications to discover hidden declarations and inspect their ownership
|
||||||
|
through the existing `Metadata.History` API without reloading configuration or
|
||||||
|
resolving unselected credentials.
|
||||||
|
|
||||||
|
See the [layered metadata contract](../config.md#layered-metadata) for current
|
||||||
|
behavior.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This is a backward-compatible API addition. Existing APIs, configuration syntax,
|
||||||
|
diagnostic codes, minimum Go version, and supported platforms are unchanged.
|
||||||
|
Existing consumers do not need to change their code or configuration.
|
||||||
|
|
||||||
|
Returned paths are detached, unique by canonical JSON Pointer spelling, and sorted
|
||||||
|
by that spelling. The enumeration includes defaults and the root, exposes no
|
||||||
|
configuration values, and performs no I/O or secret resolution.
|
||||||
|
|
||||||
|
The private YAML dependency remains pinned to
|
||||||
|
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, confirmed as the newest available v4 release during
|
||||||
|
candidate preparation.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
No action required. Consumers that want historical path discovery can update with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.1.2
|
||||||
|
```
|
||||||
|
|
||||||
|
Then iterate over `result.Metadata().HistoricalPaths()` and use `History` for
|
||||||
|
chronological definitions or `Lookup` to determine whether each path remains
|
||||||
|
effective.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Added `Metadata.HistoricalPaths` with detached, deterministic path enumeration.
|
||||||
|
- Included effective, default, and removed paths while excluding paths that never
|
||||||
|
existed.
|
||||||
|
- Added coverage for parent removal, mapping-to-sequence shape replacement, escaped
|
||||||
|
mapping keys, detached results, zero metadata, composite base ownership, and
|
||||||
|
avoidance of superseded secret lookup.
|
||||||
|
- Documented historical path enumeration in the current configuration contract.
|
||||||
58
docs/releases/v0.2.0.md
Normal file
58
docs/releases/v0.2.0.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# ConfigKit v0.2.0
|
||||||
|
|
||||||
|
This release adds integer schema-version input and consistent canonical sequence
|
||||||
|
path handling.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Applications can retain YAML such as `version: 4` while storing and checking their
|
||||||
|
version as a Go string. The exception applies only to the path designated by
|
||||||
|
`VersionRule`; unrelated fields keep strict decoding. See the
|
||||||
|
[configuration contract](../config.md#application-schema-versions).
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This minor release intentionally changes integer schema-version input from a
|
||||||
|
`version.type` rejection to invoking the application checker with its decimal text.
|
||||||
|
Plain string destinations at that path now accept it. Public Go API shapes,
|
||||||
|
minimum Go version, and supported platforms are unchanged.
|
||||||
|
|
||||||
|
Parsed sequence indices now require canonical nonnegative decimal spelling.
|
||||||
|
Signed tokens such as `+0` and `-0`, previously accepted by some traversal paths,
|
||||||
|
no longer identify sequence elements. They remain valid literal mapping keys.
|
||||||
|
Version checking, decoding, and metadata matching now agree on this boundary.
|
||||||
|
|
||||||
|
The private YAML dependency remains pinned to `go.yaml.in/yaml/v4 v4.0.0-rc.6`,
|
||||||
|
confirmed as the newest available v4 release during candidate preparation. Linux
|
||||||
|
candidate checks use Go 1.25.5 and Go 1.26.7. The maintainer confirmed native macOS
|
||||||
|
checks at `244104f` and approved carrying that result across the documentation-only
|
||||||
|
candidate changes under the [recorded exception](../policy/release.md#policy-exceptions).
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing string schema versions need no configuration changes. Applications using
|
||||||
|
integer YAML versions can retain them and use a plain string field plus a
|
||||||
|
`VersionRule`. Review the application checker: it now receives integer identifiers
|
||||||
|
as text, and remains responsible for rejecting unsupported versions. Fractions,
|
||||||
|
exponents, booleans, and containers remain invalid version kinds.
|
||||||
|
|
||||||
|
Replace signed sequence-index tokens in configured paths with canonical indices
|
||||||
|
(for example, `/versions/+0` becomes `/versions/0`). Do not change signed mapping
|
||||||
|
keys. Literal integers elsewhere still do not decode into strings, and numeric
|
||||||
|
destinations retain their existing range checks.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Added load-local, path-scoped integer-to-string schema-version decoding without
|
||||||
|
mutating original input or introducing general scalar coercion.
|
||||||
|
- Preserved numeric historical input, string effective output, decoding
|
||||||
|
reconciliation, source attribution, and secret redaction.
|
||||||
|
- Unified sequence path interpretation and added regression coverage for signed
|
||||||
|
tokens, literal mapping keys, and explicit key/index distinctions.
|
||||||
|
- Updated maintained Notarius-shaped fixtures and migration guidance for quoted
|
||||||
|
and integer version forms.
|
||||||
|
- Retired completed implementation and remediation roadmaps to Git history.
|
||||||
70
docs/releases/v0.3.0.md
Normal file
70
docs/releases/v0.3.0.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# ConfigKit v0.3.0
|
||||||
|
|
||||||
|
This release adds source provenance during application resolution, separates the
|
||||||
|
public API from its internal engine, and moves macOS to best-effort support.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Resolvers can query fixed entry-state metadata and already-acquired sources using
|
||||||
|
`Resolution.InputInfo` and `Resolution.Source`. This supports application-owned
|
||||||
|
decisions such as resolving relative paths against their declaring configuration
|
||||||
|
files. See the [resolution contract](../config.md#recorded-application-resolution)
|
||||||
|
and the maintained [source-aware path example](../consumers/README.md#source-aware-application-path-policy).
|
||||||
|
|
||||||
|
The root `configkit` package now owns public types and explicit wrappers around a
|
||||||
|
single private engine. Internal changes no longer automatically expand public
|
||||||
|
method sets through shared type aliases. See the [component overview](../internal/overview.md)
|
||||||
|
and [facade decision](../adr/0006-public-facade.md).
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
Existing consumer imports, exported API signatures, configuration syntax,
|
||||||
|
precedence, secret handling, callback lifetime, and ownership behavior are
|
||||||
|
preserved. The two resolution query methods are additive and available only
|
||||||
|
during an active resolution callback; returned records remain detached. The
|
||||||
|
library supplies provenance, not a new filesystem normalization policy.
|
||||||
|
|
||||||
|
macOS on amd64 and arm64 is now best-effort rather than release-validated.
|
||||||
|
Neither native macOS evidence nor Darwin cross-builds are required for publication,
|
||||||
|
and macOS-specific failures do not by themselves block a release. Linux on amd64
|
||||||
|
and arm64 retains release-gated builds and native Linux validation. See the
|
||||||
|
[platform support policy](../policy/release.md#platform-support).
|
||||||
|
|
||||||
|
The module still requires Go 1.25 or newer. The private YAML dependency remains
|
||||||
|
pinned to `go.yaml.in/yaml/v4 v4.0.0-rc.6`, confirmed as the newest available v4
|
||||||
|
release during preparation. It is still a prerelease dependency, isolated behind
|
||||||
|
the existing private parser boundary; no dependency upgrade is included.
|
||||||
|
|
||||||
|
Linux candidate validation uses Go 1.25.14 and Go 1.26.8, including the complete
|
||||||
|
release checker, race tests, consumer examples, filesystem fixtures, and Linux
|
||||||
|
cross-builds. No native macOS validation is claimed for this candidate.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.3.0
|
||||||
|
```
|
||||||
|
|
||||||
|
No code or configuration migration is required for the facade refactor. Adopt the
|
||||||
|
new provenance queries only if your resolver needs them. Applications retain
|
||||||
|
ownership of relative-path bases and fallback/error behavior.
|
||||||
|
|
||||||
|
macOS consumers should account for the reduced validation commitment and perform
|
||||||
|
their own application checks when upgrading if macOS behavior is important to
|
||||||
|
their deployment.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Added callback-scoped, fixed entry metadata and source lookup without additional
|
||||||
|
filesystem access or credential resolution.
|
||||||
|
- Added a maintained Notarius-shaped path-normalization example with provenance,
|
||||||
|
frozen effective output, and explanation coverage.
|
||||||
|
- Moved loading, schema, resolution, provenance/history, secret, and rendering
|
||||||
|
machinery into one internal engine while keeping public named types root-owned.
|
||||||
|
- Added exact-type secret and callback adapters, with regression coverage for
|
||||||
|
missing version checkers and nil/zero closed-context failures.
|
||||||
|
- Added type-aware public-boundary checks covering aliases, collections, generic
|
||||||
|
constraints, and exported method sets; restored public reference documentation.
|
||||||
|
- Removed macOS release gates and retained required Linux validation.
|
||||||
|
- Retired completed feature, implementation, and remediation documents to Git
|
||||||
|
history and repaired navigation to current contract and component owners.
|
||||||
43
docs/releases/v0.3.1.md
Normal file
43
docs/releases/v0.3.1.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# ConfigKit v0.3.1
|
||||||
|
|
||||||
|
This release fixes secrets-directory bootstrap when the directory contains Git
|
||||||
|
metadata or other dot-prefixed entries.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`LoadSecretsDir` now ignores dot-prefixed files, directories, and symlinks,
|
||||||
|
including `.git`, `.gitignore`, and `.DS_Store`. These entries no longer prevent
|
||||||
|
otherwise valid secrets from loading and are omitted from the bootstrap report.
|
||||||
|
See the [bootstrap contract](../config.md#explicit-secrets-directory-bootstrap).
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This is a backward-compatible bug fix for previously rejected directory contents.
|
||||||
|
There are no Go API signature changes or migrations for previously accepted
|
||||||
|
inputs. Validation of non-hidden entries, existing-environment precedence, and
|
||||||
|
the existing enumeration and content limits remain unchanged. Hidden entries
|
||||||
|
still count toward the directory enumeration limit.
|
||||||
|
|
||||||
|
The minimum Go version remains 1.25. Linux remains release-validated and macOS
|
||||||
|
remains best-effort. The explicitly pinned YAML dependency remains
|
||||||
|
`go.yaml.in/yaml/v4 v4.0.0-rc.6`; reassessment found no newer published version.
|
||||||
|
Retaining this prerelease pin avoids unrelated parser changes in this patch.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
Update the module dependency and rebuild the consuming application:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.3.1
|
||||||
|
```
|
||||||
|
|
||||||
|
No configuration migration is required. A secrets directory can retain its
|
||||||
|
dot-prefixed metadata without treating those entries as secret candidates.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Filter hidden entries before filename and metadata validation and reporting.
|
||||||
|
- Cover hidden-only and mixed directories, including Git metadata and a dangling
|
||||||
|
hidden symlink, with regression tests.
|
||||||
|
- Update public API comments and the canonical bootstrap contract.
|
||||||
|
- Stop tracking local codebase-memory tooling artifacts and ignore their directory.
|
||||||
56
docs/releases/v0.4.0.md
Normal file
56
docs/releases/v0.4.0.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# ConfigKit v0.4.0
|
||||||
|
|
||||||
|
This release renames selected configuration profiles to overlays, intentionally
|
||||||
|
breaking the previous naming contract without compatibility aliases or fallback.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Selected configuration now uses `Options.Overlay` and `overlays/<name>.yml`.
|
||||||
|
Selection, merge rules, input precedence, and lifecycle behavior are unchanged.
|
||||||
|
See the [selected-overlay contract](../config.md#selected-overlays).
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This is a breaking minor release before v1.0.0. Four surfaces change:
|
||||||
|
|
||||||
|
- Go API: `Options.Profile` becomes `Options.Overlay`, `LayerProfile` becomes
|
||||||
|
`LayerOverlay`, and all seven `CodeProfile*` constants become `CodeOverlay*`.
|
||||||
|
- Selected files: `profiles/<name>.yml` becomes `overlays/<name>.yml`; there is
|
||||||
|
no legacy-directory fallback.
|
||||||
|
- Provenance: the layer string changes from `profile` to `overlay`.
|
||||||
|
- Structured diagnostics: the `profile.*` code prefix becomes `overlay.*`.
|
||||||
|
|
||||||
|
The old Go identifiers are removed. Application-owned concepts such as
|
||||||
|
`ModelProfile` and `model_profile` are unchanged. Ordinary explicit imports from
|
||||||
|
a directory named `profiles` remain valid base inputs.
|
||||||
|
|
||||||
|
The minimum Go version remains 1.25. Linux remains release-validated and macOS
|
||||||
|
remains best-effort. The YAML dependency remains pinned to
|
||||||
|
`go.yaml.in/yaml/v4 v4.0.0-rc.6`; this release does not change parser dependencies.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
Update the dependency:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.4.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Rename the affected Go identifiers, move selected files into `overlays/`, update
|
||||||
|
any handling of provenance-layer and diagnostic-code strings, and rebuild.
|
||||||
|
The maintained example helper is now `LoadNarratioOverlay` rather than
|
||||||
|
`LoadNarratioProfile`. Application CLI flag names remain application-owned;
|
||||||
|
ConfigKit does not require renaming domain-specific profile concepts.
|
||||||
|
See the [migration guide](../consumers/README.md#selected-overlay-migration).
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Rename the selected feature consistently across the public facade, internal
|
||||||
|
engine, diagnostics, source labels, examples, and current documentation.
|
||||||
|
- Add regression coverage for the new directory, absence of legacy fallback,
|
||||||
|
public values, explanations, and preservation of application profile fields.
|
||||||
|
- Align contributor policies with the current shared standard.
|
||||||
|
- Simplify release validation to one local minimum-toolchain check. CI is not a
|
||||||
|
release gate; successful publication of the intended tag completes the
|
||||||
|
source-only release. See the [release procedure](../release.md).
|
||||||
|
- Retire completed overlay and release-workflow planning documents to Git history.
|
||||||
28
docs/roadmap/deferred.md
Normal file
28
docs/roadmap/deferred.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Deferred Scope
|
||||||
|
|
||||||
|
This document owns intentionally excluded features, not a committed delivery plan.
|
||||||
|
The [architecture policy](../policy/architecture.md) governs changes to these
|
||||||
|
boundaries. Implemented behavior belongs in the [configuration contract](../config.md)
|
||||||
|
and consumer adoption in the [consumer guide](../consumers/README.md).
|
||||||
|
|
||||||
|
## Explicit non-goals and deferred scope
|
||||||
|
|
||||||
|
General environment-variable configuration overlays are not supported. Typed secret
|
||||||
|
references and explicit directory bootstrap are the environment convention. Literal
|
||||||
|
credential configuration and implicit secret-directory discovery are unsupported.
|
||||||
|
|
||||||
|
The initial release deliberately excludes:
|
||||||
|
|
||||||
|
- automatic `conf.d` discovery;
|
||||||
|
- remote imports, arbitrary providers, and additional configuration formats;
|
||||||
|
- custom merge strategies, sequence merging, reset operators, and overlay inheritance;
|
||||||
|
- parser-specific or custom structural-decoding extension APIs;
|
||||||
|
- generic dynamic-subtree conversion and module-registry APIs;
|
||||||
|
- automatic schema migrations;
|
||||||
|
- hot reload, runtime resource replacement, and remote resource validation;
|
||||||
|
- a bundled CLI framework or application commands; and
|
||||||
|
- configuration diffs and fingerprints.
|
||||||
|
|
||||||
|
Applications may build their own validate, effective-value, source, bootstrap, and
|
||||||
|
explanation commands around the library. Broadening these boundaries requires
|
||||||
|
concrete consumer evidence, compatibility review, and an explicit design change.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,83 +0,0 @@
|
|||||||
# ConfigKit Initial Target Record
|
|
||||||
|
|
||||||
## Status and ownership
|
|
||||||
|
|
||||||
The initial feature target is implemented. This document now records the intended
|
|
||||||
outcome, source foundations, and deliberately deferred scope without duplicating the
|
|
||||||
current configuration contract or Go API reference. Ordered implementation history
|
|
||||||
and acceptance evidence remain in the [implementation plan](implementation.md) until
|
|
||||||
the maintainer retires completed roadmaps while preparing a release candidate.
|
|
||||||
|
|
||||||
The [architecture policy](../policy/architecture.md) owns responsibilities,
|
|
||||||
dependency direction, confidentiality, and the two success criteria. The
|
|
||||||
[configuration contract](../config.md) and Go declarations own implemented behavior.
|
|
||||||
The [consumer adoption guide](../consumers/README.md) owns consumer fit and migration
|
|
||||||
guidance. Accepted rationale remains in the [ADRs](../development.md#task-specific-reading-guide).
|
|
||||||
|
|
||||||
## Initial outcome
|
|
||||||
|
|
||||||
ConfigKit is one opinionated Go library for strict YAML configuration loading. It
|
|
||||||
removes source handling, parsing, explicit import/profile traversal, composition,
|
|
||||||
precedence, presence tracking, provenance, secret lookup/bootstrap, diagnostics, and
|
|
||||||
safe inspection plumbing from applications. Applications retain their types,
|
|
||||||
defaults, schema-version policy, recorded normalization, semantic validation, CLI
|
|
||||||
parsing, remote acquisition, domain algorithms, resource checks, and runtime setup.
|
|
||||||
|
|
||||||
The maintained Narratio-, Notarius-, WeatherReporter-, and Seriatim-shaped examples
|
|
||||||
demonstrate both architecture success criteria: simple consumers avoid optional
|
|
||||||
composition machinery, complex consumers use the same conventions, and domain logic
|
|
||||||
remains recognizable application code. These examples are migration evidence, not a
|
|
||||||
claim that any sibling application has migrated.
|
|
||||||
|
|
||||||
## Implementation foundations
|
|
||||||
|
|
||||||
The implementation was generalized from maintainer-authorized application code and
|
|
||||||
tests at fixed clean revisions. The [extraction inventory](../internal/extraction.md)
|
|
||||||
records exact revisions, symbols, adapted cases, intentional semantic differences,
|
|
||||||
and the boundary that keeps sibling checkouts out of builds and tests. Links from
|
|
||||||
that inventory or historical implementation evidence to sibling source are optional
|
|
||||||
in isolated clones; immutable revision references remain authoritative evidence.
|
|
||||||
|
|
||||||
The module uses `go.yaml.in/yaml/v4` only behind `internal/yaml`. ConfigKit owns the
|
|
||||||
document model, field binding, scalar grammar, errors, limits, and output behavior.
|
|
||||||
No upstream type, option, plugin, error chain, hidden parser value, or generic
|
|
||||||
constraint crosses the public API. The package-level import-boundary test enforces
|
|
||||||
this isolation for production, tests, and maintained examples.
|
|
||||||
|
|
||||||
## Current feature owners
|
|
||||||
|
|
||||||
| Initial feature area | Current owner |
|
|
||||||
| --- | --- |
|
|
||||||
| Sources, lifecycle, limits, YAML/scalars, decoding, and ownership | [Configuration contract](../config.md) and Go reference comments |
|
|
||||||
| Paths, presence, origins, sources, and definition history | [Layered metadata](../config.md#layered-metadata), [`path.go`](../../path.go), and [`metadata.go`](../../metadata.go) |
|
|
||||||
| Strict composition, imports, globs, source identity, and profiles | [Strict composition](../config.md#strict-document-composition), [explicit imports](../config.md#explicit-imports), and [selected profiles](../config.md#selected-profiles) |
|
|
||||||
| Static defaults, null, types, projection, and custom text scalars | [Defaults and typed decoding](../config.md#defaults-and-typed-decoding), [schema binding](../config.md#application-schema-binding), and [typed projection](../config.md#typed-projection-and-ownership) |
|
|
||||||
| Schema versions, recorded resolution, and semantic validation | [Version contract](../config.md#application-schema-versions), [resolution](../config.md#recorded-application-resolution), and [validation](../config.md#semantic-validation) |
|
|
||||||
| Secret classification, typed references, and directory bootstrap | [Redaction](../config.md#secret-classification-and-effective-rendering), [references](../config.md#typed-secret-references), and [bootstrap](../config.md#explicit-secrets-directory-bootstrap) |
|
|
||||||
| Explicit CLI overrides and complete precedence | [CLI override contract](../config.md#explicit-cli-overrides) |
|
|
||||||
| Effective rendering and safe historical explanation | [Effective rendering](../config.md#secret-classification-and-effective-rendering) and [historical explanation](../config.md#historical-value-explanation) |
|
|
||||||
| Canonical module options and downstream adoption | [Module options](../config.md#canonical-module-options) and [consumer guide](../consumers/README.md) |
|
|
||||||
| Diagnostics and structured errors | [`records.go`](../../records.go), [`diagnostic.go`](../../diagnostic.go), and relevant configuration-contract sections |
|
|
||||||
| Internal components and dependency placement | [Component overview](../internal/overview.md) and [architecture policy](../policy/architecture.md) |
|
|
||||||
|
|
||||||
## Explicit non-goals and deferred scope
|
|
||||||
|
|
||||||
General environment-variable configuration overlays are not supported. Typed secret
|
|
||||||
references and explicit directory bootstrap are the environment convention. Literal
|
|
||||||
credential configuration and implicit secret-directory discovery are unsupported.
|
|
||||||
|
|
||||||
The initial release deliberately excludes:
|
|
||||||
|
|
||||||
- automatic `conf.d` discovery;
|
|
||||||
- remote imports, arbitrary providers, and additional configuration formats;
|
|
||||||
- custom merge strategies, sequence merging, reset operators, and profile inheritance;
|
|
||||||
- parser-specific or custom structural-decoding extension APIs;
|
|
||||||
- generic dynamic-subtree conversion and module-registry APIs;
|
|
||||||
- automatic schema migrations;
|
|
||||||
- hot reload, runtime resource replacement, and remote resource validation;
|
|
||||||
- a bundled CLI framework or application commands; and
|
|
||||||
- configuration diffs and fingerprints.
|
|
||||||
|
|
||||||
Applications may build their own validate, effective-value, source, bootstrap, and
|
|
||||||
explanation commands around the library. Broadening these boundaries requires
|
|
||||||
concrete consumer evidence, compatibility review, and an explicit design change.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# Release Readiness
|
|
||||||
|
|
||||||
## Status and ownership
|
|
||||||
|
|
||||||
ConfigKit's implementation is prepared for release-candidate validation, but no
|
|
||||||
release candidate has been selected or publication authorized. The maintainer
|
|
||||||
preparing the first release owns the remaining gates under the
|
|
||||||
[temporary bootstrap exception](../policy/release.md#temporary-bootstrap-deferral).
|
|
||||||
This evidence record remains until candidate preparation retires completed roadmap
|
|
||||||
material in favor of the operational [release procedure](../release.md) and normal
|
|
||||||
release notes.
|
|
||||||
|
|
||||||
## Preparation evidence
|
|
||||||
|
|
||||||
| Preparation item | Status and evidence |
|
|
||||||
| --- | --- |
|
|
||||||
| Module and support matrix | Closed: the runbook records module `gitea.maximumdirect.net/eric/ConfigKit`, minimum Go 1.25 with a patched 1.25 plus newer supported toolchain, and Linux/macOS on amd64/arm64. |
|
|
||||||
| Maintainer release procedure | Closed: `docs/release.md` owns access prerequisites, candidate recording, complete validation, exact remote/commit/tag guards, source-only publication, consumer retrieval, and correction. |
|
|
||||||
| Automated source checker | Closed for current-source preparation: `scripts/release-check.sh` passed on 2026-09-06 with Go 1.25.5 and Go 1.26.7 on linux/amd64. It performs disposable-copy module hygiene, cached/offline tests, race, vet, native and four-target cross-builds, formatting/whitespace, examples, import isolation, and local-link gates. The exact candidate must rerun it. |
|
|
||||||
| Consumer verification procedure | Closed as procedure: the runbook retrieves an exact published version into a clean temporary module with no local replacement and tests/builds it. Actual retrieval remains a post-publication gate. |
|
|
||||||
| Secret/precedence/explanation evidence | Closed locally: current contracts and deterministic tests cover bootstrap skip/partial failure, credential isolation, defaults/base/profile/CLI precedence, historical redaction, and maintained consumer startup examples. |
|
|
||||||
| Parser dependency reassessment | Closed for preparation: on 2026-09-06, `go list -m -versions go.yaml.in/yaml/v4` and `go list -m -json go.yaml.in/yaml/v4@latest` both showed `v4.0.0-rc.6` as the newest available release. The explicit pin is retained; a candidate compatibility review must reassess and record it again. |
|
|
||||||
|
|
||||||
## Outstanding release gates
|
|
||||||
|
|
||||||
| Gate | Owner and closure evidence |
|
|
||||||
| --- | --- |
|
|
||||||
| Exact candidate and release note | Maintainer selects an unused stable version, writes `docs/releases/<version>.md`, commits all release changes, runs the checker against that exact clean commit, pushes it to `origin/main`, and records the commit. |
|
|
||||||
| Native platform execution | Maintainer records applicable checker, race, symlink, and permission-fixture results on native Linux and macOS. Current local evidence is Linux/amd64 only; cross-builds do not close macOS execution. |
|
|
||||||
| Compatibility, migration, and content review | Maintainer records public API/configuration/diagnostic review, downstream migration impact, release-note approval, and inspection for credentials, private data, generated output, unintended files, and unrelated changes. |
|
|
||||||
| Roadmap retirement | Maintainer removes obsolete completed files under `docs/roadmap/`, repairs inbound links, and reruns the checker as part of the candidate. This record and the implementation plan intentionally remain while no candidate exists. |
|
|
||||||
| Remote publication guards | Maintainer fetches authoritative `origin/main` and tags immediately before publication and proves branch, clean worktree/index, candidate commit, remote commit, release note, approvals, and unused version all agree. |
|
|
||||||
| Tag and retrieval verification | After authorization, maintainer pushes only the exact immutable source tag, verifies its peeled remote commit, and retrieves/tests/builds the exact version in a clean consumer module without `replace`. |
|
|
||||||
|
|
||||||
No tag, push, downstream migration, release note, or release publication was
|
|
||||||
performed during implementation. A green local checker does not close the exact
|
|
||||||
candidate, native macOS, remote, or post-publication gates.
|
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
# Pre-release Maintainability Remediation
|
|
||||||
|
|
||||||
## Status and scope
|
|
||||||
|
|
||||||
Status: planned; no stages have been implemented under this roadmap.
|
|
||||||
Audit baseline: `9b147bfd96aec25e0125dcae66e136e56dc7a670`.
|
|
||||||
|
|
||||||
This roadmap owns the agreed maintainability cleanup before v0.1.0. It replaces
|
|
||||||
the completed implementation-defect remediation record, whose findings, fixes,
|
|
||||||
and validation evidence remain available in Git history. It does not reopen
|
|
||||||
those defects or replace the original [implementation plan](implementation.md).
|
|
||||||
|
|
||||||
The audit found worthwhile consolidation and ownership improvements, rather than
|
|
||||||
a need to redesign ConfigKit. The offline default suite and vet passed at the
|
|
||||||
audit baseline. That evidence is not validation of future changes or a release
|
|
||||||
candidate.
|
|
||||||
|
|
||||||
Read the [development guide](../development.md),
|
|
||||||
[architecture policy](../policy/architecture.md),
|
|
||||||
[testing policy](../policy/testing.md), and
|
|
||||||
[documentation policy](../policy/documentation.md) before implementation.
|
|
||||||
The [configuration contract](../config.md) owns current semantics; the
|
|
||||||
[component overview](../internal/overview.md) owns implementation inventory.
|
|
||||||
Release preparation remains governed by the [release policy](../policy/release.md)
|
|
||||||
and [maintainer procedure](../release.md).
|
|
||||||
|
|
||||||
## Execution rules and invariants
|
|
||||||
|
|
||||||
Implement the numbered stages in order. Each stage is one reviewable unit of
|
|
||||||
work: inspect its current owners and tests, make the change, validate it, update
|
|
||||||
its status and evidence here, and commit the nonempty unit with a short plain
|
|
||||||
English message. Do not create empty commits. Record any discovered behavioral
|
|
||||||
defect separately with a reproduction rather than silently changing semantics
|
|
||||||
under a refactoring stage.
|
|
||||||
|
|
||||||
Keep the root public package and the existing `internal/document` and
|
|
||||||
`internal/yaml` packages. Do not introduce dependencies, public API changes,
|
|
||||||
provider interfaces, configurable merge policies, or a generic visitor framework.
|
|
||||||
Private file splits do not require new packages or ADRs. Parser-specific types
|
|
||||||
must remain behind the existing YAML adapter boundary.
|
|
||||||
|
|
||||||
Preserve the implemented contracts, particularly:
|
|
||||||
|
|
||||||
- Strict composition remains distinct from intentional profile/CLI overlay.
|
|
||||||
- Input precedence, final-only typed decoding, retained defaults, canonical
|
|
||||||
decoding explanation events, and recorded domain resolution remain unchanged.
|
|
||||||
- Source identity, containment, optional-import rules, limits, diagnostic codes,
|
|
||||||
source coordinates, ordering, and early callback termination remain intact.
|
|
||||||
- Semantic paths retain explicit key/index distinctions and historical shapes.
|
|
||||||
- Returned values and inspection snapshots remain detached; secret values remain
|
|
||||||
absent from history, rendering, and library-generated diagnostics.
|
|
||||||
- Resolution runs before secret lookup and validation; ordinary loading never
|
|
||||||
bootstraps environment variables implicitly.
|
|
||||||
|
|
||||||
Use focused existing behavioral tests during each stage and the baseline checks
|
|
||||||
from the development guide before committing production changes. Add tests only
|
|
||||||
for meaningful gaps exposed by extraction, not to enforce file locations or
|
|
||||||
private call choreography. Update current owning documentation alongside each
|
|
||||||
move. Planned filenames below are private implementation destinations, not new
|
|
||||||
consumer contracts.
|
|
||||||
|
|
||||||
## Stage 1 — Remove unused overlay value snapshots
|
|
||||||
|
|
||||||
Status: pending.
|
|
||||||
|
|
||||||
Evidence: `overlayRecord` in [overlay.go](../../overlay.go) retains `before` and
|
|
||||||
`after` trees; profile and CLI processing construct them and
|
|
||||||
`cloneOverlayRecords` clones them. Production consumers use the record's change
|
|
||||||
and displaced origins, while explanation uses `historyApplication` instead.
|
|
||||||
|
|
||||||
Work:
|
|
||||||
|
|
||||||
1. Reconfirm all field reads before removal. Remove only the unused overlay
|
|
||||||
snapshot fields and their construction/cloning in `overlay.go` and
|
|
||||||
[override.go](../../override.go).
|
|
||||||
2. Preserve `change`, `displaced`, input applications, and their ownership rules.
|
|
||||||
Do not remove resolution snapshots or retained decoded trees: those have
|
|
||||||
separate consumers and budget semantics.
|
|
||||||
3. Remove locals and imports made unnecessary by the deletion. Do not replace
|
|
||||||
the removed state with a new cache or history representation.
|
|
||||||
|
|
||||||
Acceptance: profile/CLI provenance, explanation replay, composition independence,
|
|
||||||
and redaction tests pass unchanged in observable behavior. Source inspection
|
|
||||||
confirms no unused overlay snapshot construction remains. An allocation benchmark
|
|
||||||
is not required to justify removing provably unread clones.
|
|
||||||
|
|
||||||
## Stage 2 — Centralize parser issue translation
|
|
||||||
|
|
||||||
Status: pending.
|
|
||||||
|
|
||||||
Evidence: [parse.go](../../parse.go) translates adapter issues inline, while
|
|
||||||
`importParseDiagnostics` in [imports.go](../../imports.go) repeats the translation
|
|
||||||
for imports and profiles.
|
|
||||||
|
|
||||||
Work:
|
|
||||||
|
|
||||||
1. Define one private `parserDiagnostics` helper in `parse.go` that maps adapter
|
|
||||||
issues to detached public diagnostics, including related locations.
|
|
||||||
2. Route selected-source, import, and profile parsing through it. Remove the
|
|
||||||
import-specific translator and keep upstream parser errors private.
|
|
||||||
3. Return canonically ordered diagnostics from the shared translation boundary.
|
|
||||||
Preserve existing codes, messages, primary/related coordinates, and source IDs.
|
|
||||||
Keep per-source parsing budgets and graph accounting at their current owners.
|
|
||||||
|
|
||||||
Acceptance: malformed selected/imported/profile input retains safe, correctly
|
|
||||||
attributed diagnostics. Use existing parser and import diagnostic tests; add a
|
|
||||||
small cross-entry regression only if a source path lacks equivalent coverage.
|
|
||||||
|
|
||||||
## Stage 3 — Share bounded regular-file read mechanics
|
|
||||||
|
|
||||||
Status: pending.
|
|
||||||
|
|
||||||
Evidence: `readSource`, `importTraversal.readImport`, and `readProfileFile` repeat
|
|
||||||
preflight stat, open, post-open stat, regular-file checks, size checks, and bounded
|
|
||||||
reads in [source.go](../../source.go), [imports.go](../../imports.go), and
|
|
||||||
[profile.go](../../profile.go).
|
|
||||||
|
|
||||||
Work:
|
|
||||||
|
|
||||||
1. Introduce private helpers in `source_file.go` for regular-file opening and
|
|
||||||
bounded reading. Use narrow function parameters for stat/open operations so
|
|
||||||
callers can supply `os.Stat`/`os.Open` or root-bound methods. Do not introduce
|
|
||||||
an exported filesystem interface or substitute unrestricted opening for
|
|
||||||
`os.Root` access.
|
|
||||||
2. Keep preflight before open and post-open verification. Close every opened
|
|
||||||
file on all paths. Preserve the existing treatment of read-only file close
|
|
||||||
errors; root-directory close diagnostics are a separate responsibility.
|
|
||||||
3. Return private classified failures with enough distinction for callers to
|
|
||||||
map errors safely. Optional imports may suppress only the currently allowed
|
|
||||||
missing-target failures at stat/open, not later read failures. Do not expose
|
|
||||||
filesystem errors downstream through wrapping.
|
|
||||||
4. Keep source metadata, canonicalization, containment, per-source/aggregate
|
|
||||||
budgets, byte accounting, and caller-specific limit diagnostics outside the
|
|
||||||
shared mechanics. Pass the computed byte limit into the reader and retain
|
|
||||||
both the size precheck and limit-plus-one read enforcement.
|
|
||||||
5. Reuse bounded read mechanics for reader/byte sources only where it simplifies
|
|
||||||
existing code without changing single-use or ownership behavior. Keep secrets
|
|
||||||
bootstrap independent: its symlink, environment, and reporting policies differ.
|
|
||||||
|
|
||||||
Acceptance: source, import, profile, FIFO, containment, symlink, optional-missing,
|
|
||||||
exact-budget, and reader-ownership tests pass. Retain the deadline-isolated FIFO
|
|
||||||
regression. Cover private failure classification only where public tests cannot
|
|
||||||
reliably induce an important filesystem failure. Run race tests for this unit.
|
|
||||||
|
|
||||||
## Stage 4 — Establish shared document traversal ownership
|
|
||||||
|
|
||||||
Status: pending.
|
|
||||||
|
|
||||||
Evidence: `indexDocument`, `walkDocumentPaths`, and `projectedSensitivity` repeat
|
|
||||||
semantic tree traversal in [load_metadata.go](../../load_metadata.go),
|
|
||||||
[metadata.go](../../metadata.go), and [secret.go](../../secret.go). General document
|
|
||||||
lookup currently lives in [load.go](../../load.go).
|
|
||||||
|
|
||||||
Work:
|
|
||||||
|
|
||||||
1. Create root-private `document_walk.go` with a nil-safe preorder walker taking
|
|
||||||
a starting `Path` and a callback receiving both path and node. Visit mappings
|
|
||||||
in stored order and sequences in index order; construct explicit Key/Index
|
|
||||||
segments. It visits value nodes, not synthetic mapping-key nodes.
|
|
||||||
2. Move `indexDocument` and `documentAtPath` to this owner. Implement indexing,
|
|
||||||
metadata shape collection, and projected sensitivity collection using the
|
|
||||||
walker where their traversal semantics match.
|
|
||||||
3. Document callback borrowing: callers must clone paths or nodes when retaining
|
|
||||||
mutable state. Preserve existing defensive copies and avoid sharing mutable
|
|
||||||
metadata across results.
|
|
||||||
4. Keep path-bearing helpers in the root package so `internal/document` does not
|
|
||||||
depend on the public package. Pure node representation and cloning stay in
|
|
||||||
`internal/document`.
|
|
||||||
5. Leave specialized traversals explicit where they need inherited origins,
|
|
||||||
pruning, node identity deduplication, or different counting. In particular,
|
|
||||||
input limits count mapping keys and retained-history limits count shared nodes
|
|
||||||
once; do not implement either using the new walker without those semantics.
|
|
||||||
|
|
||||||
Acceptance: numeric map keys versus sequence indices, escaped paths, unknown
|
|
||||||
paths, historical shape changes, deleted descendants, dynamic secret tags, and
|
|
||||||
metadata detachment remain protected by existing tests. No public path behavior
|
|
||||||
or predicate coverage changes. Do not add tests that assert walker call counts.
|
|
||||||
|
|
||||||
## Stage 5 — Separate typed projection, cloning, and comparison
|
|
||||||
|
|
||||||
Status: pending.
|
|
||||||
|
|
||||||
Evidence: [projection.go](../../projection.go) combines roughly 622 lines of
|
|
||||||
projection, detached cloning, dynamic schema handling, cycle/limit enforcement,
|
|
||||||
and structural comparison. Value equality deliberately ignores sensitivity,
|
|
||||||
which validation must check separately.
|
|
||||||
|
|
||||||
Work:
|
|
||||||
|
|
||||||
1. Keep canonical typed projection in `projection.go`; move cloning into
|
|
||||||
`clone.go`, shared reflection operation state and cycle helpers into
|
|
||||||
`value_operation.go`, and value-tree comparison into `document_compare.go`.
|
|
||||||
Preserve the descriptor-driven approach and per-operation schema cache.
|
|
||||||
2. Rename the private equality/comparison entry points to make configuration-value
|
|
||||||
semantics explicit. Document that location and sensitivity metadata are
|
|
||||||
excluded, mapping order is immaterial, and sequences are compared atomically
|
|
||||||
for change reporting. Update all callers without changing their semantics.
|
|
||||||
3. Give validator mutation comparison a named private owner in `validation.go`.
|
|
||||||
It must combine projected value changes and sensitivity changes, while
|
|
||||||
retaining private resolved-secret-state auditing as an additional check.
|
|
||||||
Do not make resolver value comparison start treating sensitivity as a value.
|
|
||||||
4. Preserve the distinction between cloning unresolved public secret references
|
|
||||||
and publication cloning of already validated private state. Name/document
|
|
||||||
the clone mode explicitly rather than leaving an unexplained boolean at
|
|
||||||
sensitive call sites.
|
|
||||||
5. Replace custom `cmpString` calls with the standard library comparator and
|
|
||||||
remove that helper. Do not perform unrelated test-style rewrites.
|
|
||||||
|
|
||||||
Acceptance: projection/cloning tests cover nil versus empty, custom text codecs,
|
|
||||||
inline fields, dynamic types, cycles, and limits. Validator mutation regressions
|
|
||||||
must still reject equal-text sensitivity changes and secret-state changes.
|
|
||||||
Callback ownership and secret lookup tests must prove publication does not share
|
|
||||||
borrowed values or reread the environment. Run race tests for this unit.
|
|
||||||
|
|
||||||
## Stage 6 — Clarify source assembly and override ownership
|
|
||||||
|
|
||||||
Status: pending.
|
|
||||||
|
|
||||||
Evidence: [imports.go](../../imports.go) mixes filesystem context, directives,
|
|
||||||
graph traversal, and diagnostics; [override.go](../../override.go) combines value
|
|
||||||
construction/validation and application. Shared composition cloning is located
|
|
||||||
inside the override feature, while profile overlay repeats it.
|
|
||||||
|
|
||||||
Work:
|
|
||||||
|
|
||||||
1. Keep import traversal and route diagnostics in `imports.go`. Move filesystem
|
|
||||||
context validation/canonicalization/containment to `filesystem.go`, and
|
|
||||||
directive structures/parsing/fragment restrictions to `directives.go`.
|
|
||||||
Give source-independent node counting an appropriate shared document owner,
|
|
||||||
with its mapping-key accounting documented.
|
|
||||||
2. Keep override validation and preparation in `override.go`, move parser-neutral
|
|
||||||
value building to `override_value.go`, and application/displacement mechanics
|
|
||||||
to `override_apply.go`.
|
|
||||||
3. Move `cloneComposedDocument` next to `composedDocument` in `compose.go`; use it
|
|
||||||
for both profile overlay and overrides. Ensure every retained field is copied
|
|
||||||
according to its existing ownership contract.
|
|
||||||
4. Keep strict composition, overlay, and history replay separate. Share small
|
|
||||||
mechanics when identical; do not introduce merge strategy flags or one general
|
|
||||||
merge engine. Preserve the existing profile-versus-CLI recording behavior.
|
|
||||||
5. Simplify profile root cleanup using deferred failure-path cleanup plus an
|
|
||||||
explicit checked success-path close. Preserve primary failure diagnostics and
|
|
||||||
the existing successful-read root-close error diagnostic; avoid double-close
|
|
||||||
effects. Import traversal cleanup may remain explicit where already clear.
|
|
||||||
|
|
||||||
Acceptance: imports/globs, directive restrictions, source ordering, profiles,
|
|
||||||
overrides, version restrictions, limits, and composition independence tests pass.
|
|
||||||
No new cross-package dependencies appear. Root handles close on every path and
|
|
||||||
profile/CLI provenance remains identical.
|
|
||||||
|
|
||||||
## Stage 7 — Extract explicit loader lifecycle phases
|
|
||||||
|
|
||||||
Status: pending.
|
|
||||||
|
|
||||||
Evidence: `loadWithSecretLookup` in [load.go](../../load.go) coordinates roughly
|
|
||||||
200 lines of source assembly, decoding, callbacks, history, secrets, and result
|
|
||||||
publication, including repeated creation of a composition handoff.
|
|
||||||
|
|
||||||
Work:
|
|
||||||
|
|
||||||
1. Keep the exported entry point and a readable linear coordinator in `load.go`.
|
|
||||||
Move option selection to `options.go`, input assembly to `load_input.go`, and
|
|
||||||
typed lifecycle/result publication to `load_result.go`. Move version checking
|
|
||||||
beside the validation machinery rather than keeping it among document helpers.
|
|
||||||
2. Use small private handoff structs for assembled input and necessary phase
|
|
||||||
outputs, not a mutable all-purpose loader object or a long list of output
|
|
||||||
parameters. Reuse one composition initialization helper for profile and CLI
|
|
||||||
processing, including NoSource override handling and source byte/node totals.
|
|
||||||
3. Preserve the exact existing order of option validation, parsing/import checks,
|
|
||||||
schema validation, version checking, profile/CLI application, defaults factory,
|
|
||||||
typed decoding, resolution, retained-history limit enforcement, secret lookup,
|
|
||||||
validation, metadata/classification, and result publication.
|
|
||||||
4. Keep diagnostic/source accumulation explicit. Warnings survive successful
|
|
||||||
phases; errors stop dependent work and never return a usable partial Result.
|
|
||||||
5. Preserve both ownership barriers: detach resolver-borrowed values before secret
|
|
||||||
resolution and validation, then detach equal-valued validator replacements
|
|
||||||
before publication while preserving validated secret state. Do not remove
|
|
||||||
either clone as apparently redundant work.
|
|
||||||
6. Group Result's frozen inspection state only if it clarifies this handoff; do
|
|
||||||
not add an additional history copy or change caller ownership of Config.
|
|
||||||
|
|
||||||
Acceptance: lifecycle ordering/early-stop tests, defaults-only loading, all input
|
|
||||||
combinations, callback ownership, secrets, warning retention, and complete
|
|
||||||
provenance agreement pass. Keep orchestration visible enough to verify phase
|
|
||||||
ordering by reading the coordinator. Run the complete race suite.
|
|
||||||
|
|
||||||
## Stage 8 — Separate explanation replay, snapshots, and rendering
|
|
||||||
|
|
||||||
Status: pending.
|
|
||||||
|
|
||||||
Evidence: [explain.go](../../explain.go) combines approximately 576 lines of public
|
|
||||||
records, replay algorithms, snapshot redaction, retained-history accounting, and
|
|
||||||
human-readable output.
|
|
||||||
|
|
||||||
Work:
|
|
||||||
|
|
||||||
1. Keep public explanation records and Explain coordination in `explain.go`.
|
|
||||||
Move application/snapshot replay and history budgeting to `history.go`, safe
|
|
||||||
snapshot projection to `snapshot.go`, and WriteExplanation with its formatting
|
|
||||||
helpers to `explain_render.go`.
|
|
||||||
2. Make effective rendering and explanation snapshots use the existing shared
|
|
||||||
redaction implementation; do not create separate sensitivity rules.
|
|
||||||
3. Retain the canonical decoding reconciliation event between input replay and
|
|
||||||
domain resolution, overlapping resolution intent coalescing, added/deleted
|
|
||||||
paths, effective source attribution, and historical secret classification.
|
|
||||||
4. Document the differences between metadata origin records and value replay:
|
|
||||||
they serve different query contracts but must agree on effective results.
|
|
||||||
Do not replace them with a new event-store abstraction in this cleanup.
|
|
||||||
5. Preserve writer failure behavior and ownership of retained trees. Do not cache
|
|
||||||
explanations or optimize full-tree replay without measured need.
|
|
||||||
|
|
||||||
Acceptance: explanation, effective rendering, history budget, provenance, secret
|
|
||||||
redaction, and writer-failure tests pass without output changes. Container and
|
|
||||||
leaf histories still reconcile with the frozen effective typed configuration.
|
|
||||||
|
|
||||||
## Stage 9 — Complete ownership documentation and integration validation
|
|
||||||
|
|
||||||
Status: pending.
|
|
||||||
|
|
||||||
Work:
|
|
||||||
|
|
||||||
1. Review the final code for obsolete helpers, duplicate traversal/translation,
|
|
||||||
unread retained state, stale comments, and imports left by earlier stages.
|
|
||||||
Confirm file splits reflect discrete responsibilities rather than line quotas.
|
|
||||||
2. Update the component overview to describe the actual owners and fix its stale
|
|
||||||
claim that metadata queries are empty. Keep detailed consumer semantics in
|
|
||||||
their existing contract; repair navigation and moved-file links throughout
|
|
||||||
documentation, including this roadmap's evidence links if necessary.
|
|
||||||
3. State the retained dependency direction clearly: public lifecycle coordination
|
|
||||||
uses private source/document/schema/inspection helpers; the owned document
|
|
||||||
representation has no dependency on the root package; only the private YAML
|
|
||||||
adapter imports the upstream YAML package.
|
|
||||||
4. Run the full baseline and race suites, local documentation and dependency
|
|
||||||
boundary checks, and the complete source-candidate checker described in the
|
|
||||||
release procedure with available qualifying toolchains. Record exact versions,
|
|
||||||
host platform, results, and any unavailable platform evidence. Cross-builds
|
|
||||||
are not native execution evidence.
|
|
||||||
5. Review the accumulated diff for public API/configuration/diagnostic changes.
|
|
||||||
Expected outcome: no consumer migration or dependency change. If any contract
|
|
||||||
change is discovered, resolve it explicitly before closing this roadmap.
|
|
||||||
6. Record completion evidence and commit this final nonempty unit. Release
|
|
||||||
preparation must subsequently retire completed roadmap material and validate
|
|
||||||
the exact committed candidate under release policy. Completing this roadmap
|
|
||||||
does not create a tag, push refs, or authorize publication.
|
|
||||||
|
|
||||||
Acceptance: all stages are complete with recorded validation, existing behavioral
|
|
||||||
regressions remain protected, documentation matches the new ownership layout,
|
|
||||||
and the worktree is clean after commits. Required release gates remain visible
|
|
||||||
in the release procedure rather than being declared satisfied by this refactor.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
None required to begin implementation. The package boundaries, compatibility
|
|
||||||
constraints, ordered units, and acceptance criteria above are decided. Routine
|
|
||||||
private naming adjustments are acceptable when they preserve those decisions;
|
|
||||||
new behavioral discoveries require explicit evidence and disposition.
|
|
||||||
@@ -4,6 +4,7 @@ package consumers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -28,14 +29,14 @@ type NarratioConfig struct {
|
|||||||
|
|
||||||
// LoadNarratio loads the maintained import fixture with Narratio-shaped rules.
|
// LoadNarratio loads the maintained import fixture with Narratio-shaped rules.
|
||||||
func LoadNarratio(path string) (*configkit.Result[NarratioConfig], error) {
|
func LoadNarratio(path string) (*configkit.Result[NarratioConfig], error) {
|
||||||
return LoadNarratioProfile(path, "")
|
return LoadNarratioOverlay(path, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadNarratioProfile selects a ConfigKit configuration overlay independently
|
// LoadNarratioOverlay selects a ConfigKit configuration overlay independently
|
||||||
// from the application's ModelProfile field.
|
// from the application's ModelProfile field.
|
||||||
func LoadNarratioProfile(path, profile string) (*configkit.Result[NarratioConfig], error) {
|
func LoadNarratioOverlay(path, overlay string) (*configkit.Result[NarratioConfig], error) {
|
||||||
return configkit.Load(configkit.File(path), configkit.Options[NarratioConfig]{
|
return configkit.Load(configkit.File(path), configkit.Options[NarratioConfig]{
|
||||||
Imports: true, Profile: profile,
|
Imports: true, Overlay: overlay,
|
||||||
Definition: configkit.Definition[NarratioConfig]{
|
Definition: configkit.Definition[NarratioConfig]{
|
||||||
Defaults: func() NarratioConfig { return NarratioConfig{PollInterval: time.Minute} },
|
Defaults: func() NarratioConfig { return NarratioConfig{PollInterval: time.Minute} },
|
||||||
Resolve: resolveNarratio,
|
Resolve: resolveNarratio,
|
||||||
@@ -83,7 +84,7 @@ type ObjectBinding struct {
|
|||||||
Options map[string]any `yaml:"options"`
|
Options map[string]any `yaml:"options"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotariusConfig demonstrates string versions and dynamic catalog options.
|
// NotariusConfig demonstrates string-backed versions and dynamic catalog options.
|
||||||
type NotariusConfig struct {
|
type NotariusConfig struct {
|
||||||
Version string `yaml:"version"`
|
Version string `yaml:"version"`
|
||||||
Bindings []ObjectBinding `yaml:"bindings"`
|
Bindings []ObjectBinding `yaml:"bindings"`
|
||||||
@@ -93,7 +94,7 @@ type NotariusConfig struct {
|
|||||||
func LoadNotarius(path string) (*configkit.Result[NotariusConfig], error) {
|
func LoadNotarius(path string) (*configkit.Result[NotariusConfig], error) {
|
||||||
return configkit.Load(configkit.File(path), configkit.Options[NotariusConfig]{
|
return configkit.Load(configkit.File(path), configkit.Options[NotariusConfig]{
|
||||||
Definition: configkit.Definition[NotariusConfig]{
|
Definition: configkit.Definition[NotariusConfig]{
|
||||||
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: requireVersion("v1")},
|
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: requireVersion("4")},
|
||||||
Validate: validateNotarius,
|
Validate: validateNotarius,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -146,6 +147,69 @@ func NotariusWindow(configuration NotariusConfig, binding int) (int, bool) {
|
|||||||
return int(value), true
|
return int(value), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NotariusPathConfig demonstrates application-owned interpretation of a
|
||||||
|
// path-valued configuration field. ConfigKit supplies provenance; this example
|
||||||
|
// selects its own relative-path bases.
|
||||||
|
type NotariusPathConfig struct {
|
||||||
|
Reference string `yaml:"reference"`
|
||||||
|
Sensitive string `yaml:"sensitive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadNotariusPaths loads the path-bearing Notarius-shaped example. InvocationDir
|
||||||
|
// is the application-supplied base for relative CLI overrides; it is never read
|
||||||
|
// from the process working directory.
|
||||||
|
func LoadNotariusPaths(path, invocationDir string, overrides ...configkit.Override) (*configkit.Result[NotariusPathConfig], error) {
|
||||||
|
return configkit.Load(configkit.File(path), configkit.Options[NotariusPathConfig]{
|
||||||
|
Imports: true, Overrides: overrides,
|
||||||
|
Definition: configkit.Definition[NotariusPathConfig]{
|
||||||
|
Defaults: func() NotariusPathConfig { return NotariusPathConfig{Reference: "defaults/reference.txt"} },
|
||||||
|
Resolve: func(configuration *NotariusPathConfig, resolution *configkit.Resolution) error {
|
||||||
|
resolveNotariusReference(configuration, resolution, invocationDir)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Secret: func(path configkit.Path) bool { return path.String() == "/sensitive" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveNotariusReference(configuration *NotariusPathConfig, resolution *configkit.Resolution, invocationDir string) {
|
||||||
|
path := configkit.Keys("reference")
|
||||||
|
if configuration.Reference == "" || filepath.IsAbs(configuration.Reference) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, found := resolution.InputInfo(path)
|
||||||
|
if !found {
|
||||||
|
resolution.Warning(path, "relative reference has no entry provenance")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
base := ""
|
||||||
|
if info.Origin.Layer == configkit.LayerCLI {
|
||||||
|
base = invocationDir
|
||||||
|
if base == "" {
|
||||||
|
resolution.Warning(path, "relative CLI reference has no invocation directory")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sourceID := "source-1" // ConfigKit's primary-source identity convention.
|
||||||
|
if info.Origin.Layer != configkit.LayerDefault {
|
||||||
|
if len(info.Origin.Locations) != 1 {
|
||||||
|
resolution.Warning(path, "relative reference has no unambiguous declaring source")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sourceID = info.Origin.Locations[0].SourceID
|
||||||
|
}
|
||||||
|
source, exists := resolution.Source(sourceID)
|
||||||
|
if !exists || source.Path == "" {
|
||||||
|
resolution.Warning(path, "relative reference has no declaring file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base = filepath.Dir(source.Path)
|
||||||
|
}
|
||||||
|
configkit.Normalize(resolution, path, &configuration.Reference,
|
||||||
|
filepath.Join(base, configuration.Reference), "resolved relative reference from entry source")
|
||||||
|
}
|
||||||
|
|
||||||
// ModuleOptions is a WeatherReporter-shaped union of known module option fields.
|
// ModuleOptions is a WeatherReporter-shaped union of known module option fields.
|
||||||
type ModuleOptions struct {
|
type ModuleOptions struct {
|
||||||
WindowUnits *int `yaml:"window_units"`
|
WindowUnits *int `yaml:"window_units"`
|
||||||
|
|||||||
@@ -26,7 +26,13 @@ func Example_notarius() {
|
|||||||
result, err := consumers.LoadNotarius(fixture("notarius", "config.yml"))
|
result, err := consumers.LoadNotarius(fixture("notarius", "config.yml"))
|
||||||
window, ok := consumers.NotariusWindow(result.Config, 0)
|
window, ok := consumers.NotariusWindow(result.Config, 0)
|
||||||
fmt.Println(result.Config.Version, result.Config.Bindings[0].Object, window, ok, err)
|
fmt.Println(result.Config.Version, result.Config.Bindings[0].Object, window, ok, err)
|
||||||
// Output: v1 summary 12 true <nil>
|
// Output: 4 summary 12 true <nil>
|
||||||
|
}
|
||||||
|
|
||||||
|
func Example_notariusPaths() {
|
||||||
|
result, err := consumers.LoadNotariusPaths(fixture("notarius-paths", "main.yml"), "/invocation")
|
||||||
|
fmt.Println(filepath.Base(result.Config.Reference), err)
|
||||||
|
// Output: imported.txt <nil>
|
||||||
}
|
}
|
||||||
|
|
||||||
func Example_weatherReporter() {
|
func Example_weatherReporter() {
|
||||||
@@ -86,10 +92,13 @@ func TestMaintainedConsumerFixturesPreserveDomainOutcomes(t *testing.T) {
|
|||||||
if origin, ok := narratio.Metadata().Lookup(configkit.Keys("artifacts", "transcript", "output")); !ok || origin.Origin.Layer != configkit.LayerResolution {
|
if origin, ok := narratio.Metadata().Lookup(configkit.Keys("artifacts", "transcript", "output")); !ok || origin.Origin.Layer != configkit.LayerResolution {
|
||||||
t.Fatalf("artifact origin = %#v, %v", origin, ok)
|
t.Fatalf("artifact origin = %#v, %v", origin, ok)
|
||||||
}
|
}
|
||||||
profiled, err := consumers.LoadNarratioProfile(fixture("narratio", "pipeline.yml"), "testing")
|
overlaid, err := consumers.LoadNarratioOverlay(fixture("narratio", "pipeline.yml"), "testing")
|
||||||
if err != nil || profiled.Config.PollInterval.String() != "2m0s" ||
|
if err != nil || overlaid.Config.PollInterval.String() != "2m0s" ||
|
||||||
profiled.Config.ModelProfile != "concise" || len(profiled.Sources()) != 3 {
|
overlaid.Config.ModelProfile != "concise" || len(overlaid.Sources()) != 3 {
|
||||||
t.Fatalf("configuration/application profiles = %#v, %v", profiled, err)
|
t.Fatalf("configuration overlay and application model profile = %#v, %v", overlaid, err)
|
||||||
|
}
|
||||||
|
if source := overlaid.Sources()[2]; source.Label != "overlays/testing.yml" || source.Layer != configkit.LayerOverlay {
|
||||||
|
t.Fatalf("Narratio overlay source = %#v", source)
|
||||||
}
|
}
|
||||||
|
|
||||||
notarius, err := consumers.LoadNotarius(fixture("notarius", "config.yml"))
|
notarius, err := consumers.LoadNotarius(fixture("notarius", "config.yml"))
|
||||||
@@ -100,6 +109,10 @@ func TestMaintainedConsumerFixturesPreserveDomainOutcomes(t *testing.T) {
|
|||||||
if _, ok := notarius.Config.Bindings[0].Options["window_units"].(int64); !ok {
|
if _, ok := notarius.Config.Bindings[0].Options["window_units"].(int64); !ok {
|
||||||
t.Fatalf("dynamic integer type = %T", notarius.Config.Bindings[0].Options["window_units"])
|
t.Fatalf("dynamic integer type = %T", notarius.Config.Bindings[0].Options["window_units"])
|
||||||
}
|
}
|
||||||
|
integerNotarius, err := consumers.LoadNotarius(fixture("notarius", "integer-version.yml"))
|
||||||
|
if err != nil || integerNotarius.Config.Version != notarius.Config.Version || integerNotarius.Config.Version != "4" {
|
||||||
|
t.Fatalf("Notarius version forms = %#v / %#v, %v", notarius.Config, integerNotarius, err)
|
||||||
|
}
|
||||||
|
|
||||||
weather, err := consumers.LoadWeather(configkit.File(fixture("weather", "config.yml")))
|
weather, err := consumers.LoadWeather(configkit.File(fixture("weather", "config.yml")))
|
||||||
if err != nil || weather.Config.Reports["daily"].Modules[1].Options.Sections[0] != "short_term" {
|
if err != nil || weather.Config.Reports["daily"].Modules[1].Options.Sections[0] != "short_term" {
|
||||||
@@ -124,8 +137,19 @@ func TestMigrationFixturesFailWithStableCodesAndPaths(t *testing.T) {
|
|||||||
path string
|
path string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "integer schema version",
|
name: "unsupported application version",
|
||||||
load: func() error { _, err := consumers.LoadNotarius(fixture("notarius", "integer-version.yml")); return err },
|
load: func() error {
|
||||||
|
_, err := consumers.LoadNotarius(fixture("notarius", "unsupported-version.yml"))
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
code: configkit.CodeVersionError, path: "/version",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-integer numeric version",
|
||||||
|
load: func() error {
|
||||||
|
_, err := consumers.LoadNotarius(fixture("notarius", "noninteger-version.yml"))
|
||||||
|
return err
|
||||||
|
},
|
||||||
code: configkit.CodeVersionType, path: "/version",
|
code: configkit.CodeVersionType, path: "/version",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -179,7 +203,7 @@ func TestMigrationFixturesFailWithStableCodesAndPaths(t *testing.T) {
|
|||||||
if len(diagnostics) == 0 || diagnostics[0].Code != test.code || diagnostics[0].Path.String() != test.path {
|
if len(diagnostics) == 0 || diagnostics[0].Code != test.code || diagnostics[0].Path.String() != test.path {
|
||||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
if strings.Contains(diagnostics[0].Message, "wrong-before-profile") {
|
if strings.Contains(diagnostics[0].Message, "wrong-before-overlay") {
|
||||||
t.Fatalf("diagnostic exposed raw parser input: %#v", diagnostics[0])
|
t.Fatalf("diagnostic exposed raw parser input: %#v", diagnostics[0])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -225,12 +249,89 @@ func TestWeatherLoadedAndConstructedRulesAgreeWithoutMutation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNotariusPathExampleUsesEntryProvenance(t *testing.T) {
|
||||||
|
main := fixture("notarius-paths", "main.yml")
|
||||||
|
declared := fixture("notarius-paths", "declarations", "path.yml")
|
||||||
|
declaredCanonical := canonicalFixture(t, declared)
|
||||||
|
wantImported := filepath.Join(filepath.Dir(declaredCanonical), "..", "assets", "imported.txt")
|
||||||
|
result, err := consumers.LoadNotariusPaths(main, filepath.Join(t.TempDir(), "invocation"))
|
||||||
|
if err != nil || result.Config.Reference != wantImported {
|
||||||
|
t.Fatalf("imported reference = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
var effective bytes.Buffer
|
||||||
|
if err := result.WriteEffective(&effective); err != nil || !strings.Contains(effective.String(), wantImported) ||
|
||||||
|
strings.Contains(effective.String(), "PATH_REFERENCE_SECRET_CANARY") {
|
||||||
|
t.Fatalf("effective output = %q, %v", effective.String(), err)
|
||||||
|
}
|
||||||
|
explanation, found := result.Explain(configkit.Keys("reference"))
|
||||||
|
if !found || len(explanation.History) == 0 || explanation.History[len(explanation.History)-1].Before.YAML != "../assets/imported.txt\n" ||
|
||||||
|
len(explanation.History[len(explanation.History)-1].Reasons) != 1 || explanation.History[len(explanation.History)-1].Reasons[0] != "resolved relative reference from entry source" {
|
||||||
|
t.Fatalf("reference explanation = %#v, %v", explanation, found)
|
||||||
|
}
|
||||||
|
sensitive, found := result.Explain(configkit.Keys("sensitive"))
|
||||||
|
var rendered bytes.Buffer
|
||||||
|
writeErr := result.WriteExplanation(&rendered, configkit.Keys("sensitive"))
|
||||||
|
if !found || !sensitive.Effective.Redacted || writeErr != nil ||
|
||||||
|
strings.Contains(fmt.Sprintf("%#v %s", sensitive, rendered.String()), "PATH_REFERENCE_SECRET_CANARY") {
|
||||||
|
t.Fatalf("sensitive inspection = %#v, %q, %v", sensitive, rendered.String(), writeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
defaults := fixture("notarius-paths", "defaults.yml")
|
||||||
|
defaultCanonical := canonicalFixture(t, defaults)
|
||||||
|
defaultResult, err := consumers.LoadNotariusPaths(defaults, filepath.Join(t.TempDir(), "invocation"))
|
||||||
|
wantDefault := filepath.Join(filepath.Dir(defaultCanonical), "defaults", "reference.txt")
|
||||||
|
if err != nil || defaultResult.Config.Reference != wantDefault {
|
||||||
|
t.Fatalf("default reference = %#v, %v", defaultResult, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
invocation := filepath.Join(t.TempDir(), "caller")
|
||||||
|
cliResult, err := consumers.LoadNotariusPaths(main, invocation,
|
||||||
|
configkit.Override{Path: configkit.Keys("reference"), Value: "cli/reference.txt", Flag: "--reference"})
|
||||||
|
wantCLI := filepath.Join(invocation, "cli", "reference.txt")
|
||||||
|
if err != nil || cliResult.Config.Reference != wantCLI {
|
||||||
|
t.Fatalf("CLI reference = %#v, %v", cliResult, err)
|
||||||
|
}
|
||||||
|
missingBase, err := consumers.LoadNotariusPaths(main, "",
|
||||||
|
configkit.Override{Path: configkit.Keys("reference"), Value: "cli/reference.txt", Flag: "--reference"})
|
||||||
|
if err != nil || missingBase.Config.Reference != "cli/reference.txt" || len(missingBase.Diagnostics()) != 1 ||
|
||||||
|
missingBase.Diagnostics()[0].Code != configkit.CodeResolveWarning {
|
||||||
|
t.Fatalf("missing CLI base = %#v, %v", missingBase, err)
|
||||||
|
}
|
||||||
|
absolute := filepath.Join(t.TempDir(), "absolute.txt")
|
||||||
|
absoluteResult, err := consumers.LoadNotariusPaths(main, invocation,
|
||||||
|
configkit.Override{Path: configkit.Keys("reference"), Value: absolute, Flag: "--reference"})
|
||||||
|
if err != nil || absoluteResult.Config.Reference != absolute {
|
||||||
|
t.Fatalf("absolute reference = %#v, %v", absoluteResult, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
target := fixture("notarius-paths", "symlink-target.yml")
|
||||||
|
link := filepath.Join(t.TempDir(), "linked-config.yml")
|
||||||
|
if err := os.Symlink(target, link); err != nil {
|
||||||
|
t.Skipf("symlink fixture unavailable: %v", err)
|
||||||
|
}
|
||||||
|
symlinkResult, err := consumers.LoadNotariusPaths(link, invocation)
|
||||||
|
targetCanonical := canonicalFixture(t, target)
|
||||||
|
wantSymlink := filepath.Join(filepath.Dir(targetCanonical), "..", "assets", "target.txt")
|
||||||
|
if err != nil || symlinkResult.Config.Reference != wantSymlink {
|
||||||
|
t.Fatalf("symlink reference = %#v, %v", symlinkResult, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func fixture(parts ...string) string {
|
func fixture(parts ...string) string {
|
||||||
_, file, _, _ := runtime.Caller(0)
|
_, file, _, _ := runtime.Caller(0)
|
||||||
values := append([]string{filepath.Dir(file), "testdata"}, parts...)
|
values := append([]string{filepath.Dir(file), "testdata"}, parts...)
|
||||||
return filepath.Join(values...)
|
return filepath.Join(values...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func canonicalFixture(t *testing.T, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
canonical, err := filepath.EvalSymlinks(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return canonical
|
||||||
|
}
|
||||||
|
|
||||||
func preserveEnvironment(name string) func() {
|
func preserveEnvironment(name string) func() {
|
||||||
value, present := os.LookupEnv(name)
|
value, present := os.LookupEnv(name)
|
||||||
return func() {
|
return func() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
_configkit:
|
configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: artifacts.yml
|
- path: artifacts.yml
|
||||||
poll_interval: 1m
|
poll_interval: 1m
|
||||||
|
|||||||
1
examples/consumers/testdata/notarius-paths/declarations/path.yml
vendored
Normal file
1
examples/consumers/testdata/notarius-paths/declarations/path.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
reference: ../assets/imported.txt
|
||||||
1
examples/consumers/testdata/notarius-paths/defaults.yml
vendored
Normal file
1
examples/consumers/testdata/notarius-paths/defaults.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
sensitive: PATH_REFERENCE_SECRET_CANARY
|
||||||
4
examples/consumers/testdata/notarius-paths/main.yml
vendored
Normal file
4
examples/consumers/testdata/notarius-paths/main.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
configkit:
|
||||||
|
imports:
|
||||||
|
- path: declarations/path.yml
|
||||||
|
sensitive: PATH_REFERENCE_SECRET_CANARY
|
||||||
2
examples/consumers/testdata/notarius-paths/symlink-target.yml
vendored
Normal file
2
examples/consumers/testdata/notarius-paths/symlink-target.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
reference: ../assets/target.txt
|
||||||
|
sensitive: PATH_REFERENCE_SECRET_CANARY
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
version: v1
|
version: "4"
|
||||||
bindings:
|
bindings:
|
||||||
- object: summary
|
- object: summary
|
||||||
options:
|
options:
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
version: 1
|
version: 4
|
||||||
bindings: []
|
bindings:
|
||||||
|
- object: summary
|
||||||
|
options:
|
||||||
|
window_units: 12
|
||||||
|
format: markdown
|
||||||
|
|||||||
2
examples/consumers/testdata/notarius/noninteger-version.yml
vendored
Normal file
2
examples/consumers/testdata/notarius/noninteger-version.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
version: 4.0
|
||||||
|
bindings: []
|
||||||
2
examples/consumers/testdata/notarius/unsupported-version.yml
vendored
Normal file
2
examples/consumers/testdata/notarius/unsupported-version.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
version: 5
|
||||||
|
bindings: []
|
||||||
576
explain.go
576
explain.go
@@ -1,576 +0,0 @@
|
|||||||
package configkit
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
|
||||||
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
|
|
||||||
)
|
|
||||||
|
|
||||||
const historyNodeLimit = 4_000_000
|
|
||||||
|
|
||||||
var (
|
|
||||||
errExplanationUnavailable = errors.New("configkit: configuration explanation is unavailable")
|
|
||||||
errExplanationUnknownPath = errors.New("configkit: configuration explanation path is unknown")
|
|
||||||
)
|
|
||||||
|
|
||||||
// ValueSnapshot is a detached, deterministic YAML representation of one value.
|
|
||||||
// Present distinguishes absence or deletion from an explicit null value.
|
|
||||||
type ValueSnapshot struct {
|
|
||||||
Present bool
|
|
||||||
Redacted bool
|
|
||||||
YAML string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValueChange describes one explicit definition or audited domain operation.
|
|
||||||
type ValueChange struct {
|
|
||||||
Before ValueSnapshot
|
|
||||||
After ValueSnapshot
|
|
||||||
Origin Origin
|
|
||||||
Reasons []string
|
|
||||||
Dependencies []Path
|
|
||||||
}
|
|
||||||
|
|
||||||
// SecretReferenceInfo describes a successfully resolved effective reference.
|
|
||||||
// Name is the environment-variable name, never its value.
|
|
||||||
type SecretReferenceInfo struct {
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Explanation is a detached safe account of one effective or historical path.
|
|
||||||
type Explanation struct {
|
|
||||||
Path Path
|
|
||||||
Effective ValueSnapshot
|
|
||||||
Origin Origin
|
|
||||||
History []ValueChange
|
|
||||||
SecretReference *SecretReferenceInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
type historyApplication struct {
|
|
||||||
layer Layer
|
|
||||||
path Path
|
|
||||||
value *document.Node
|
|
||||||
origin Origin
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneHistoryApplications(values []historyApplication) []historyApplication {
|
|
||||||
if values == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cloned := make([]historyApplication, len(values))
|
|
||||||
for index, value := range values {
|
|
||||||
cloned[index] = historyApplication{
|
|
||||||
layer: value.layer, path: value.path.clone(), value: value.value,
|
|
||||||
origin: cloneOrigin(value.origin),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cloned
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneSecretReferences(values map[string]string) map[string]string {
|
|
||||||
if values == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cloned := make(map[string]string, len(values))
|
|
||||||
for path, name := range values {
|
|
||||||
cloned[path] = name
|
|
||||||
}
|
|
||||||
return cloned
|
|
||||||
}
|
|
||||||
|
|
||||||
// Explain returns safe effective and chronological values for a known final or
|
|
||||||
// historical path. Unknown and malformed paths return false.
|
|
||||||
func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
|
||||||
if r == nil || r.final == nil || r.baseline == nil || !r.metadata.hasHistoricalPath(path) {
|
|
||||||
return Explanation{}, false
|
|
||||||
}
|
|
||||||
explanation := Explanation{Path: path.clone(), Effective: valueSnapshot(r.final, path, r.secrets)}
|
|
||||||
if info, ok := r.metadata.Lookup(path); ok {
|
|
||||||
explanation.Origin = cloneOrigin(info.Origin)
|
|
||||||
}
|
|
||||||
|
|
||||||
working := document.Clone(r.baseline)
|
|
||||||
if _, present := documentAtPath(working, path); present {
|
|
||||||
explanation.History = append(explanation.History, ValueChange{
|
|
||||||
After: valueSnapshot(working, path, r.secrets), Origin: Origin{Layer: LayerDefault},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for _, application := range r.applications {
|
|
||||||
operationPath, relevant := applicationOperationPath(application, path)
|
|
||||||
var before ValueSnapshot
|
|
||||||
if relevant {
|
|
||||||
before = valueSnapshot(working, path, r.secrets)
|
|
||||||
}
|
|
||||||
applyHistoryApplication(&working, application)
|
|
||||||
if !relevant {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
after := valueSnapshot(working, path, r.secrets)
|
|
||||||
if !before.Present && !after.Present {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
explanation.History = append(explanation.History, ValueChange{
|
|
||||||
Before: before,
|
|
||||||
After: after,
|
|
||||||
Origin: historyApplicationOrigin(application, operationPath),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Intermediate input replacements do not permanently suppress defaults:
|
|
||||||
// decoding applies them to the final input tree. Show that reconciliation
|
|
||||||
// explicitly, including canonical scalar conversions, before domain changes.
|
|
||||||
replayed, _ := documentAtPath(working, path)
|
|
||||||
decoded, _ := documentAtPath(r.input, path)
|
|
||||||
if !documentNodesEqual(replayed, decoded) {
|
|
||||||
origin := cloneOrigin(r.inputOrigins[path.String()])
|
|
||||||
origin.Operation = ChangeDecoding
|
|
||||||
explanation.History = append(explanation.History, ValueChange{
|
|
||||||
Before: valueSnapshot(working, path, r.secrets),
|
|
||||||
After: valueSnapshot(r.input, path, r.secrets),
|
|
||||||
Origin: origin,
|
|
||||||
Reasons: []string{"applied retained defaults and canonical typed decoding"},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
working = document.Clone(r.input)
|
|
||||||
for _, snapshot := range coalescedResolutionSnapshots(r.resolution) {
|
|
||||||
relevant := pathsIntersect(snapshot.change.Path, path)
|
|
||||||
var before ValueSnapshot
|
|
||||||
if relevant {
|
|
||||||
before = valueSnapshot(working, path, r.secrets)
|
|
||||||
}
|
|
||||||
replaceHistoryPath(&working, snapshot.change.Path, snapshot.after)
|
|
||||||
if !relevant {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
after := valueSnapshot(working, path, r.secrets)
|
|
||||||
if !before.Present && !after.Present {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
explanation.History = append(explanation.History, ValueChange{
|
|
||||||
Before: before,
|
|
||||||
After: after,
|
|
||||||
Origin: cloneOrigin(snapshot.change.Origin),
|
|
||||||
Reasons: append([]string(nil), snapshot.change.Reasons...),
|
|
||||||
Dependencies: clonePaths(snapshot.change.Dependencies),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if !explanation.Effective.Present && len(explanation.History) == 0 {
|
|
||||||
return Explanation{}, false
|
|
||||||
}
|
|
||||||
if name, ok := r.secretReferences[path.String()]; ok && !pathIsSecret(r.secrets, path) {
|
|
||||||
explanation.SecretReference = &SecretReferenceInfo{Name: name}
|
|
||||||
}
|
|
||||||
return explanation, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func applicationOperationPath(application historyApplication, query Path) (Path, bool) {
|
|
||||||
if !application.path.valid() || application.value == nil {
|
|
||||||
return Path{}, false
|
|
||||||
}
|
|
||||||
if application.path.String() != "" {
|
|
||||||
if pathsIntersect(application.path, query) {
|
|
||||||
return application.path.clone(), true
|
|
||||||
}
|
|
||||||
return Path{}, false
|
|
||||||
}
|
|
||||||
if len(query.segments) == 0 {
|
|
||||||
return Path{}, true
|
|
||||||
}
|
|
||||||
for length := 1; length <= len(query.segments); length++ {
|
|
||||||
prefix := Path{segments: clonePathSegments(query.segments[:length], 0)}
|
|
||||||
node, present := documentAtPath(application.value, prefix)
|
|
||||||
if !present {
|
|
||||||
return Path{}, false
|
|
||||||
}
|
|
||||||
if length == len(query.segments) || node.Kind != document.Mapping {
|
|
||||||
return prefix, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Path{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func historyApplicationOrigin(application historyApplication, operationPath Path) Origin {
|
|
||||||
if application.origin.Layer != "" {
|
|
||||||
return cloneOrigin(application.origin)
|
|
||||||
}
|
|
||||||
origin := Origin{Layer: application.layer}
|
|
||||||
relative, ok := relativeHistoryPath(application.path, operationPath)
|
|
||||||
if !ok {
|
|
||||||
return origin
|
|
||||||
}
|
|
||||||
if node, present := documentAtPath(application.value, relative); present {
|
|
||||||
origin.Locations = []Location{publicLocation(node.Location)}
|
|
||||||
}
|
|
||||||
return origin
|
|
||||||
}
|
|
||||||
|
|
||||||
func relativeHistoryPath(root, path Path) (Path, bool) {
|
|
||||||
if !pathContains(root, path) {
|
|
||||||
return Path{}, false
|
|
||||||
}
|
|
||||||
return Path{segments: clonePathSegments(path.segments[len(root.segments):], 0)}, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func pathsIntersect(left, right Path) bool {
|
|
||||||
return pathContains(left, right) || pathContains(right, left)
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyHistoryApplication(root **document.Node, application historyApplication) {
|
|
||||||
if application.path.String() == "" {
|
|
||||||
mergeHistoryDocument(root, application.value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mergeHistoryAtPath(root, application.path, application.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeHistoryDocument(target **document.Node, incoming *document.Node) {
|
|
||||||
if incoming == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if *target == nil || (*target).Kind != document.Mapping || incoming.Kind != document.Mapping {
|
|
||||||
*target = document.Clone(incoming)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, entry := range incoming.Mapping {
|
|
||||||
position := documentEntryIndex((*target).Mapping, entry.Key)
|
|
||||||
if position < 0 {
|
|
||||||
cloned := entry
|
|
||||||
cloned.Value = document.Clone(entry.Value)
|
|
||||||
(*target).Mapping = append((*target).Mapping, cloned)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
mergeHistoryDocument(&(*target).Mapping[position].Value, entry.Value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node) {
|
|
||||||
if root == nil || *root == nil || len(path.segments) == 0 {
|
|
||||||
mergeHistoryDocument(root, incoming)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
current := *root
|
|
||||||
for index, segment := range path.segments {
|
|
||||||
if current.Kind != document.Mapping || segment.kind == pathSegmentIndex {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
position := documentEntryIndex(current.Mapping, segment.key)
|
|
||||||
if index == len(path.segments)-1 {
|
|
||||||
if position < 0 {
|
|
||||||
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: document.Clone(incoming)})
|
|
||||||
} else {
|
|
||||||
mergeHistoryDocument(¤t.Mapping[position].Value, incoming)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if position < 0 {
|
|
||||||
child := &document.Node{Kind: document.Mapping}
|
|
||||||
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: child})
|
|
||||||
current = child
|
|
||||||
} else {
|
|
||||||
current = current.Mapping[position].Value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolution records describe one before/final transformation, not assignment
|
|
||||||
// order. A parent already carries its descendants' final values; merge overlapping
|
|
||||||
// intent into that event rather than replay a child assignment a second time.
|
|
||||||
func coalescedResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSnapshot {
|
|
||||||
groups := make([]resolutionSnapshot, 0, len(snapshots))
|
|
||||||
for _, snapshot := range snapshots {
|
|
||||||
merged := false
|
|
||||||
for i := range groups {
|
|
||||||
if !pathContains(groups[i].change.Path, snapshot.change.Path) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, reason := range snapshot.change.Reasons {
|
|
||||||
groups[i].change.Reasons = appendUniqueString(groups[i].change.Reasons, reason)
|
|
||||||
}
|
|
||||||
for _, dependency := range snapshot.change.Dependencies {
|
|
||||||
groups[i].change.Dependencies = appendUniquePath(groups[i].change.Dependencies, dependency)
|
|
||||||
}
|
|
||||||
if snapshot.change.Origin.Operation == ChangeNormalization {
|
|
||||||
groups[i].change.Origin.Operation = ChangeNormalization
|
|
||||||
}
|
|
||||||
merged = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if !merged {
|
|
||||||
snapshot.change = cloneChange(snapshot.change)
|
|
||||||
groups = append(groups, snapshot)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return groups
|
|
||||||
}
|
|
||||||
|
|
||||||
func replaceHistoryPath(root **document.Node, path Path, replacement *document.Node) {
|
|
||||||
if root == nil || *root == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(path.segments) == 0 {
|
|
||||||
*root = document.Clone(replacement)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
current := *root
|
|
||||||
for index, segment := range path.segments {
|
|
||||||
last := index == len(path.segments)-1
|
|
||||||
switch current.Kind {
|
|
||||||
case document.Mapping:
|
|
||||||
if segment.kind == pathSegmentIndex {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
position := documentEntryIndex(current.Mapping, segment.key)
|
|
||||||
if position < 0 {
|
|
||||||
if replacement == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
child := &document.Node{Kind: document.Mapping}
|
|
||||||
if last {
|
|
||||||
child = document.Clone(replacement)
|
|
||||||
}
|
|
||||||
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: child})
|
|
||||||
if last {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
current = child
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if last {
|
|
||||||
if replacement == nil {
|
|
||||||
current.Mapping = append(current.Mapping[:position], current.Mapping[position+1:]...)
|
|
||||||
} else {
|
|
||||||
current.Mapping[position].Value = document.Clone(replacement)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
current = current.Mapping[position].Value
|
|
||||||
case document.Sequence:
|
|
||||||
position, ok := schemaIndex(segment)
|
|
||||||
if !ok || position >= len(current.Sequence) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if last {
|
|
||||||
if replacement != nil {
|
|
||||||
current.Sequence[position] = document.Clone(replacement)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
current = current.Sequence[position]
|
|
||||||
default:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func valueSnapshot(root *document.Node, path Path, secrets map[string]bool) ValueSnapshot {
|
|
||||||
node, present := documentAtPath(root, path)
|
|
||||||
if !present {
|
|
||||||
return ValueSnapshot{}
|
|
||||||
}
|
|
||||||
safe, redacted := safeSnapshotDocument(node, secrets, path)
|
|
||||||
var output bytes.Buffer
|
|
||||||
if err := yamladapter.Write(&output, safe); err != nil {
|
|
||||||
return ValueSnapshot{Present: true, Redacted: true, YAML: "'[REDACTED]'\n"}
|
|
||||||
}
|
|
||||||
return ValueSnapshot{Present: true, Redacted: redacted, YAML: output.String()}
|
|
||||||
}
|
|
||||||
|
|
||||||
func safeSnapshotDocument(node *document.Node, secrets map[string]bool, path Path) (*document.Node, bool) {
|
|
||||||
if node == nil {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
if pathIsSecret(secrets, path) {
|
|
||||||
return &document.Node{Kind: document.String, Text: "[REDACTED]"}, true
|
|
||||||
}
|
|
||||||
result := *node
|
|
||||||
redacted := false
|
|
||||||
switch node.Kind {
|
|
||||||
case document.Mapping:
|
|
||||||
result.Mapping = make([]document.Entry, len(node.Mapping))
|
|
||||||
copy(result.Mapping, node.Mapping)
|
|
||||||
sortDocumentMapping(result.Mapping)
|
|
||||||
for index := range result.Mapping {
|
|
||||||
entry := &result.Mapping[index]
|
|
||||||
entry.Value, redacted = safeSnapshotChild(entry.Value, secrets, path.Key(entry.Key), redacted)
|
|
||||||
}
|
|
||||||
case document.Sequence:
|
|
||||||
result.Sequence = make([]*document.Node, len(node.Sequence))
|
|
||||||
for index, child := range node.Sequence {
|
|
||||||
result.Sequence[index], redacted = safeSnapshotChild(child, secrets, path.Index(index), redacted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &result, redacted
|
|
||||||
}
|
|
||||||
|
|
||||||
func safeSnapshotChild(node *document.Node, secrets map[string]bool, path Path, prior bool) (*document.Node, bool) {
|
|
||||||
safe, redacted := safeSnapshotDocument(node, secrets, path)
|
|
||||||
return safe, prior || redacted
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortDocumentMapping(entries []document.Entry) {
|
|
||||||
slices.SortFunc(entries, func(left, right document.Entry) int {
|
|
||||||
return strings.Compare(left.Key, right.Key)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func historyNodesWithinLimit(baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool {
|
|
||||||
return historyNodesWithin(historyNodeLimit, baseline, input, final, applications, resolutions)
|
|
||||||
}
|
|
||||||
|
|
||||||
func historyNodesWithin(limit int, baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool {
|
|
||||||
seen := make(map[*document.Node]bool)
|
|
||||||
count := 0
|
|
||||||
var visit func(*document.Node) bool
|
|
||||||
visit = func(node *document.Node) bool {
|
|
||||||
if node == nil || seen[node] {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
seen[node] = true
|
|
||||||
count++
|
|
||||||
if count > limit {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, entry := range node.Mapping {
|
|
||||||
if !visit(entry.Value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, child := range node.Sequence {
|
|
||||||
if !visit(child) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if !visit(baseline) || !visit(input) || !visit(final) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, application := range applications {
|
|
||||||
if !visit(application.value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, resolution := range resolutions {
|
|
||||||
if !visit(resolution.before) || !visit(resolution.after) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// WriteExplanation writes a deterministic human-readable rendering of Explain.
|
|
||||||
// Unknown paths produce no output. Writer failures may leave partial output.
|
|
||||||
func (r *Result[T]) WriteExplanation(output io.Writer, path Path) error {
|
|
||||||
if r == nil || output == nil {
|
|
||||||
return errExplanationUnavailable
|
|
||||||
}
|
|
||||||
explanation, ok := r.Explain(path)
|
|
||||||
if !ok {
|
|
||||||
return errExplanationUnknownPath
|
|
||||||
}
|
|
||||||
var rendered strings.Builder
|
|
||||||
rendered.WriteString("path: ")
|
|
||||||
rendered.WriteString(strconv.Quote(explanation.Path.String()))
|
|
||||||
rendered.WriteByte('\n')
|
|
||||||
writeExplanationOrigin(&rendered, "effective source", explanation.Origin, r.sources)
|
|
||||||
writeExplanationSnapshot(&rendered, "effective (canonical typed)", explanation.Effective, 0)
|
|
||||||
if explanation.SecretReference != nil {
|
|
||||||
rendered.WriteString("secret reference: ")
|
|
||||||
rendered.WriteString(strconv.Quote(explanation.SecretReference.Name))
|
|
||||||
rendered.WriteString(" (resolved from process environment)\n")
|
|
||||||
}
|
|
||||||
rendered.WriteString("history:\n")
|
|
||||||
for _, change := range explanation.History {
|
|
||||||
rendered.WriteString(" - representation: ")
|
|
||||||
if change.Origin.Operation == ChangeDecoding {
|
|
||||||
rendered.WriteString("input to canonical typed\n")
|
|
||||||
} else if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerProfile || change.Origin.Layer == LayerCLI {
|
|
||||||
rendered.WriteString("policy-validated input\n")
|
|
||||||
} else {
|
|
||||||
rendered.WriteString("canonical typed\n")
|
|
||||||
}
|
|
||||||
writeExplanationOrigin(&rendered, " source", change.Origin, r.sources)
|
|
||||||
writeExplanationSnapshot(&rendered, " before", change.Before, 4)
|
|
||||||
writeExplanationSnapshot(&rendered, " after", change.After, 4)
|
|
||||||
for _, reason := range change.Reasons {
|
|
||||||
rendered.WriteString(" reason: ")
|
|
||||||
rendered.WriteString(strconv.Quote(reason))
|
|
||||||
rendered.WriteByte('\n')
|
|
||||||
}
|
|
||||||
for _, dependency := range change.Dependencies {
|
|
||||||
rendered.WriteString(" dependency: ")
|
|
||||||
rendered.WriteString(strconv.Quote(dependency.String()))
|
|
||||||
rendered.WriteByte('\n')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data := []byte(rendered.String())
|
|
||||||
written, err := output.Write(data)
|
|
||||||
if err == nil && written != len(data) {
|
|
||||||
return io.ErrShortWrite
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeExplanationOrigin(output *strings.Builder, label string, origin Origin, sources []SourceInfo) {
|
|
||||||
output.WriteString(label)
|
|
||||||
output.WriteString(": layer=")
|
|
||||||
output.WriteString(strconv.Quote(string(origin.Layer)))
|
|
||||||
if origin.Operation != "" {
|
|
||||||
output.WriteString(" operation=")
|
|
||||||
output.WriteString(strconv.Quote(string(origin.Operation)))
|
|
||||||
}
|
|
||||||
output.WriteByte('\n')
|
|
||||||
for _, location := range origin.Locations {
|
|
||||||
output.WriteString(" location: source=")
|
|
||||||
output.WriteString(strconv.Quote(location.SourceID))
|
|
||||||
if label := sourceLabel(sources, location.SourceID); label != "" {
|
|
||||||
output.WriteString(" label=")
|
|
||||||
output.WriteString(strconv.Quote(label))
|
|
||||||
}
|
|
||||||
output.WriteString(" line=")
|
|
||||||
output.WriteString(strconv.Itoa(location.Line))
|
|
||||||
output.WriteString(" column=")
|
|
||||||
output.WriteString(strconv.Itoa(location.Column))
|
|
||||||
output.WriteByte('\n')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sourceLabel(sources []SourceInfo, sourceID string) string {
|
|
||||||
for _, source := range sources {
|
|
||||||
if source.ID == sourceID {
|
|
||||||
return source.Label
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeExplanationSnapshot(output *strings.Builder, label string, snapshot ValueSnapshot, indent int) {
|
|
||||||
if !snapshot.Present {
|
|
||||||
output.WriteString(label)
|
|
||||||
output.WriteString(": absent\n")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
output.WriteString(label)
|
|
||||||
if snapshot.Redacted {
|
|
||||||
output.WriteString(" (redacted)")
|
|
||||||
}
|
|
||||||
output.WriteString(":\n")
|
|
||||||
prefix := strings.Repeat(" ", indent+2)
|
|
||||||
for _, line := range strings.SplitAfter(snapshot.YAML, "\n") {
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
output.WriteString(prefix)
|
|
||||||
output.WriteString(line)
|
|
||||||
if !strings.HasSuffix(line, "\n") {
|
|
||||||
output.WriteByte('\n')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
272
facade_candidate_test.go
Normal file
272
facade_candidate_test.go
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ error = (*configkit.LoadError)(nil)
|
||||||
|
_ fmt.Formatter = configkit.Secret{}
|
||||||
|
_ fmt.Stringer = configkit.Path{}
|
||||||
|
_ json.Marshaler = configkit.Secret{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFacadeValuesRemainRootOwned(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
const packagePath = "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
for _, candidate := range []reflect.Type{
|
||||||
|
reflect.TypeFor[configkit.Path](), reflect.TypeFor[configkit.Source](),
|
||||||
|
reflect.TypeFor[configkit.Secret](), reflect.TypeFor[configkit.Diagnostic](),
|
||||||
|
reflect.TypeFor[configkit.LoadError](), reflect.TypeFor[configkit.Metadata](),
|
||||||
|
reflect.TypeFor[configkit.Resolution](), reflect.TypeFor[configkit.Validation](),
|
||||||
|
reflect.TypeFor[configkit.Version](), reflect.TypeFor[configkit.VersionRule](),
|
||||||
|
reflect.TypeFor[configkit.FilesystemContext](), reflect.TypeFor[configkit.Override](),
|
||||||
|
reflect.TypeFor[configkit.Definition[struct{}]](), reflect.TypeFor[configkit.Options[struct{}]](),
|
||||||
|
reflect.TypeFor[configkit.Result[struct{}]](),
|
||||||
|
reflect.TypeFor[configkit.ValueSnapshot](), reflect.TypeFor[configkit.ValueChange](),
|
||||||
|
reflect.TypeFor[configkit.SecretReferenceInfo](), reflect.TypeFor[configkit.Explanation](),
|
||||||
|
reflect.TypeFor[configkit.SecretFileResult](), reflect.TypeFor[configkit.SecretsReport](),
|
||||||
|
reflect.TypeFor[configkit.SecretsError](),
|
||||||
|
} {
|
||||||
|
if candidate.PkgPath() != packagePath {
|
||||||
|
t.Fatalf("type %s belongs to %q", candidate, candidate.PkgPath())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
secret := configkit.SecretEnv("SAFE_NAME")
|
||||||
|
formatted := fmt.Sprintf("%#v", secret)
|
||||||
|
encoded, err := json.Marshal(secret)
|
||||||
|
if formatted == "" || err != nil || string(encoded) != `"SAFE_NAME"` {
|
||||||
|
t.Fatalf("secret rendering = %q, %s, %v", formatted, encoded, err)
|
||||||
|
}
|
||||||
|
if got := configkit.Keys("a/b~").Index(0).String(); got != "/a~1b~0/0" {
|
||||||
|
t.Fatalf("path = %q", got)
|
||||||
|
}
|
||||||
|
if got := (&configkit.LoadError{}).Error(); got != "configkit: configuration load failed" {
|
||||||
|
t.Fatalf("zero LoadError = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeInspectionUsesFrozenState(t *testing.T) {
|
||||||
|
t.Setenv("FACADE_INSPECTION_SECRET", "credential")
|
||||||
|
|
||||||
|
type configuration struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
Secret configkit.Secret `yaml:"secret"`
|
||||||
|
}
|
||||||
|
result, err := configkit.Load[configuration](configkit.Bytes("inspection.yml", []byte("value: original\nsecret: FACADE_INSPECTION_SECRET\n")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !result.Config.Secret.IsResolved() || result.Config.Secret.Value() != "credential" {
|
||||||
|
t.Fatalf("secret = %#v", result.Config.Secret)
|
||||||
|
}
|
||||||
|
explanation, ok := result.Explain(configkit.Keys("value"))
|
||||||
|
if !ok || explanation.Effective.YAML != "original\n" {
|
||||||
|
t.Fatalf("explanation = %#v, %v", explanation, ok)
|
||||||
|
}
|
||||||
|
explanation.Effective.YAML = "mutated\n"
|
||||||
|
result.Config.Value = "mutated"
|
||||||
|
var effective bytes.Buffer
|
||||||
|
if err := result.WriteEffective(&effective); err != nil || !strings.Contains(effective.String(), "value: original\n") || strings.Contains(effective.String(), "credential") {
|
||||||
|
t.Fatalf("effective = %q, %v", effective.String(), err)
|
||||||
|
}
|
||||||
|
again, ok := result.Explain(configkit.Keys("value"))
|
||||||
|
if !ok || again.Effective.YAML != "original\n" {
|
||||||
|
t.Fatalf("inspection changed = %#v, %v", again, ok)
|
||||||
|
}
|
||||||
|
secret, ok := result.Explain(configkit.Keys("secret"))
|
||||||
|
if !ok || secret.SecretReference == nil || secret.SecretReference.Name != "FACADE_INSPECTION_SECRET" {
|
||||||
|
t.Fatalf("secret explanation = %#v, %v", secret, ok)
|
||||||
|
}
|
||||||
|
var explanationOutput bytes.Buffer
|
||||||
|
if err := result.WriteExplanation(&explanationOutput, configkit.Keys("value")); err != nil || !strings.Contains(explanationOutput.String(), `path: "/value"`) {
|
||||||
|
t.Fatalf("explanation output = %q, %v", explanationOutput.String(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unknown := bytes.NewBufferString("unchanged")
|
||||||
|
if err := result.WriteExplanation(unknown, configkit.Keys("unknown")); err == nil || unknown.String() != "unchanged" {
|
||||||
|
t.Fatalf("unknown explanation = %q, %v", unknown.String(), err)
|
||||||
|
}
|
||||||
|
var nilResult *configkit.Result[configuration]
|
||||||
|
if _, ok := nilResult.Explain(configkit.Keys("value")); ok {
|
||||||
|
t.Fatal("nil result explained a path")
|
||||||
|
}
|
||||||
|
if err := nilResult.WriteEffective(io.Discard); err == nil {
|
||||||
|
t.Fatal("nil result rendered effective configuration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSecretsBootstrapUsesFacadeRecords(t *testing.T) {
|
||||||
|
const (
|
||||||
|
loadedName = "FACADE_BOOTSTRAP_LOADED"
|
||||||
|
existingName = "FACADE_BOOTSTRAP_EXISTING"
|
||||||
|
)
|
||||||
|
restore := func(name string) {
|
||||||
|
value, present := os.LookupEnv(name)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if present {
|
||||||
|
_ = os.Setenv(name, value)
|
||||||
|
} else {
|
||||||
|
_ = os.Unsetenv(name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
restore(loadedName)
|
||||||
|
restore(existingName)
|
||||||
|
if err := os.Unsetenv(loadedName); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Setenv(existingName, ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
directory := t.TempDir()
|
||||||
|
if err := os.WriteFile(directory+"/"+loadedName, []byte("bootstrap\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(directory+"/"+existingName, []byte("ignored\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
report, err := configkit.LoadSecretsDir(directory)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entries := report.Entries()
|
||||||
|
if len(entries) != 2 || os.Getenv(loadedName) != "bootstrap" {
|
||||||
|
t.Fatalf("entries/environment = %#v / %q", entries, os.Getenv(loadedName))
|
||||||
|
}
|
||||||
|
status := map[string]configkit.SecretFileResult{}
|
||||||
|
for _, entry := range entries {
|
||||||
|
status[entry.Name] = entry
|
||||||
|
}
|
||||||
|
if status[loadedName].Status != configkit.SecretFileLoaded || status[existingName].Status != configkit.SecretFileSkippedExisting || !status[existingName].ExistingEmpty {
|
||||||
|
t.Fatalf("statuses = %#v", status)
|
||||||
|
}
|
||||||
|
entries[0].Name = "mutated"
|
||||||
|
if report.Entries()[0].Name == "mutated" {
|
||||||
|
t.Fatal("report entries share mutable state")
|
||||||
|
}
|
||||||
|
var rendered bytes.Buffer
|
||||||
|
if _, err := report.WriteTo(&rendered); err != nil || strings.Contains(rendered.String(), "bootstrap") || strings.Contains(rendered.String(), "ignored") {
|
||||||
|
t.Fatalf("report rendering = %q, %v", rendered.String(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
failedReport, err := configkit.LoadSecretsDir("")
|
||||||
|
var secretsError *configkit.SecretsError
|
||||||
|
if failedReport == nil || !errors.As(err, &secretsError) || len(secretsError.Diagnostics()) != 1 || secretsError.Diagnostics()[0].Code != configkit.CodeSecretsInvalidPath {
|
||||||
|
t.Fatalf("bootstrap failure = %#v / %#v", failedReport, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeLoadBridgesLifecycleCallbacks(t *testing.T) {
|
||||||
|
t.Setenv("FACADE_SECRET_NAME", "credential")
|
||||||
|
|
||||||
|
type configuration struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
Input int `yaml:"input"`
|
||||||
|
Derived int `yaml:"derived"`
|
||||||
|
Secret configkit.Secret `yaml:"secret"`
|
||||||
|
Labels map[string]string `yaml:"labels"`
|
||||||
|
}
|
||||||
|
var retainedResolution *configkit.Resolution
|
||||||
|
var retainedValidation *configkit.Validation
|
||||||
|
versionCalls, defaultsCalls, resolveCalls, validateCalls := 0, 0, 0, 0
|
||||||
|
definition := configkit.Definition[configuration]{
|
||||||
|
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(version configkit.Version, validation *configkit.Validation) error {
|
||||||
|
versionCalls++
|
||||||
|
retainedValidation = validation
|
||||||
|
if !version.Present || version.Text != "1" {
|
||||||
|
validation.Error(configkit.Keys("version"), "unsupported version")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
Defaults: func() configuration {
|
||||||
|
defaultsCalls++
|
||||||
|
return configuration{Derived: 1, Labels: map[string]string{"default": "kept"}}
|
||||||
|
},
|
||||||
|
Resolve: func(configuration *configuration, resolution *configkit.Resolution) error {
|
||||||
|
resolveCalls++
|
||||||
|
retainedResolution = resolution
|
||||||
|
if !resolution.IsExplicit(configkit.Keys("input")) {
|
||||||
|
resolution.Error(configkit.Keys("input"), "input is required")
|
||||||
|
}
|
||||||
|
input, ok := resolution.InputInfo(configkit.Keys("input"))
|
||||||
|
if !ok || !input.Explicit {
|
||||||
|
resolution.Error(configkit.Keys("input"), "missing input provenance")
|
||||||
|
}
|
||||||
|
source, ok := resolution.Source("source-1")
|
||||||
|
if !ok || source.Label != "facade.yml" {
|
||||||
|
resolution.Error(configkit.Keys("input"), "missing source")
|
||||||
|
}
|
||||||
|
configkit.Default(resolution, configkit.Keys("derived"), &configuration.Derived, configuration.Input*2, "derived from input", configkit.Keys("input"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Validate: func(configuration *configuration, validation *configkit.Validation) {
|
||||||
|
validateCalls++
|
||||||
|
retainedValidation = validation
|
||||||
|
if !configuration.Secret.IsResolved() || configuration.Secret.Value() != "credential" {
|
||||||
|
validation.Error(configkit.Keys("secret"), "secret did not resolve")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if versionCalls != 0 || defaultsCalls != 0 || resolveCalls != 0 || validateCalls != 0 {
|
||||||
|
t.Fatal("callbacks ran while constructing options")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := configkit.Load(configkit.Bytes("facade.yml", []byte("version: 1\ninput: 3\nsecret: FACADE_SECRET_NAME\n")), configkit.Options[configuration]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if versionCalls != 1 || defaultsCalls != 1 || resolveCalls != 1 || validateCalls != 1 || result.Config.Derived != 6 {
|
||||||
|
t.Fatalf("callback counts/configuration = %d/%d/%d/%d %#v", versionCalls, defaultsCalls, resolveCalls, validateCalls, result.Config)
|
||||||
|
}
|
||||||
|
derived, ok := result.Metadata().Lookup(configkit.Keys("derived"))
|
||||||
|
if !ok || derived.Explicit || derived.Origin.Operation != configkit.ChangeDerivedDefault {
|
||||||
|
t.Fatalf("derived metadata = %#v, %v", derived, ok)
|
||||||
|
}
|
||||||
|
if sources := result.Sources(); len(sources) != 1 || sources[0].Label != "facade.yml" {
|
||||||
|
t.Fatalf("sources = %#v", sources)
|
||||||
|
}
|
||||||
|
if result.Diagnostics() != nil {
|
||||||
|
t.Fatalf("diagnostics = %#v", result.Diagnostics())
|
||||||
|
}
|
||||||
|
for _, context := range []func(){
|
||||||
|
func() { retainedResolution.IsExplicit(configkit.Keys("input")) },
|
||||||
|
func() { retainedValidation.Warning(configkit.Keys("input"), "too late") },
|
||||||
|
} {
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if recover() == nil {
|
||||||
|
t.Fatal("retained callback context remained active")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
context()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeLoadReturnsFacadeLoadError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type configuration struct {
|
||||||
|
Value int `yaml:"value"`
|
||||||
|
}
|
||||||
|
result, err := configkit.Load[configuration](configkit.Bytes("invalid.yml", []byte("value: wrong\n")))
|
||||||
|
if result != nil || err == nil {
|
||||||
|
t.Fatalf("result, err = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeDecodeType {
|
||||||
|
t.Fatalf("facade error = %#v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
85
facade_compatibility_test.go
Normal file
85
facade_compatibility_test.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ error = (*configkit.LoadError)(nil)
|
||||||
|
_ fmt.Formatter = configkit.Secret{}
|
||||||
|
_ fmt.Stringer = configkit.Path{}
|
||||||
|
_ json.Marshaler = configkit.Secret{}
|
||||||
|
_ io.WriterTo = (*configkit.SecretsReport)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
type facadeCompatibilityConfig struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
Derived string `yaml:"derived"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeCompatibility(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const packagePath = "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
for _, candidate := range []struct {
|
||||||
|
name string
|
||||||
|
type_ reflect.Type
|
||||||
|
}{
|
||||||
|
{"Path", reflect.TypeFor[configkit.Path]()},
|
||||||
|
{"Source", reflect.TypeFor[configkit.Source]()},
|
||||||
|
{"Secret", reflect.TypeFor[configkit.Secret]()},
|
||||||
|
{"Definition", reflect.TypeFor[configkit.Definition[facadeCompatibilityConfig]]()},
|
||||||
|
{"Options", reflect.TypeFor[configkit.Options[facadeCompatibilityConfig]]()},
|
||||||
|
{"Result", reflect.TypeFor[configkit.Result[facadeCompatibilityConfig]]()},
|
||||||
|
} {
|
||||||
|
if candidate.type_.PkgPath() != packagePath {
|
||||||
|
t.Errorf("%s belongs to %q, want %q", candidate.name, candidate.type_.PkgPath(), packagePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
constructed := configkit.Options[facadeCompatibilityConfig]{
|
||||||
|
Definition: configkit.Definition[facadeCompatibilityConfig]{},
|
||||||
|
Filesystem: &configkit.FilesystemContext{BaseDir: "configs", Root: "configs"},
|
||||||
|
Overrides: []configkit.Override{{
|
||||||
|
Path: configkit.Keys("value"),
|
||||||
|
Value: "override",
|
||||||
|
Flag: "--value",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if got := constructed.Overrides[0].Path.String(); got != "/value" {
|
||||||
|
t.Fatalf("constructed override path = %q, want /value", got)
|
||||||
|
}
|
||||||
|
publicResult := configkit.Result[facadeCompatibilityConfig]{Config: facadeCompatibilityConfig{Value: "constructed"}}
|
||||||
|
if publicResult.Config.Value != "constructed" {
|
||||||
|
t.Fatal("constructed result lost its public configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := configkit.Load(configkit.Bytes("facade.yml", []byte("value: input\n")), configkit.Options[facadeCompatibilityConfig]{
|
||||||
|
Definition: configkit.Definition[facadeCompatibilityConfig]{
|
||||||
|
Resolve: func(configuration *facadeCompatibilityConfig, resolution *configkit.Resolution) error {
|
||||||
|
configkit.Default(resolution, configkit.Keys("derived"), &configuration.Derived, configuration.Value+" default", "derive value")
|
||||||
|
configkit.Normalize(resolution, configkit.Keys("value"), &configuration.Value, "normalized "+configuration.Value, "normalize value")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Config != (facadeCompatibilityConfig{Value: "normalized input", Derived: "input default"}) {
|
||||||
|
t.Fatalf("Load() Config = %#v", result.Config)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = configkit.Load[facadeCompatibilityConfig](configkit.Bytes("invalid.yml", []byte("value: [\n")))
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("Load() error = %T, want *configkit.LoadError", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
51
facade_context_test.go
Normal file
51
facade_context_test.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFacadeInactiveContextsPreserveClosedFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, state := range []struct {
|
||||||
|
name string
|
||||||
|
resolution *configkit.Resolution
|
||||||
|
validation *configkit.Validation
|
||||||
|
}{
|
||||||
|
{name: "nil"},
|
||||||
|
{name: "zero", resolution: &configkit.Resolution{}, validation: &configkit.Validation{}},
|
||||||
|
} {
|
||||||
|
t.Run(state.name, func(t *testing.T) {
|
||||||
|
path := configkit.Keys("value")
|
||||||
|
var target int
|
||||||
|
for _, operation := range []struct {
|
||||||
|
name string
|
||||||
|
call func()
|
||||||
|
}{
|
||||||
|
{"IsExplicit", func() { state.resolution.IsExplicit(path) }},
|
||||||
|
{"InputInfo", func() { state.resolution.InputInfo(path) }},
|
||||||
|
{"Source", func() { state.resolution.Source("source") }},
|
||||||
|
{"Record", func() { state.resolution.Record(path, configkit.ChangeNormalization, "reason") }},
|
||||||
|
{"Resolution.Error", func() { state.resolution.Error(path, "message") }},
|
||||||
|
{"Resolution.Warning", func() { state.resolution.Warning(path, "message") }},
|
||||||
|
{"Default", func() { configkit.Default(state.resolution, path, &target, 1, "reason") }},
|
||||||
|
{"Normalize", func() { configkit.Normalize(state.resolution, path, &target, 1, "reason") }},
|
||||||
|
{"Validation.Error", func() { state.validation.Error(path, "message") }},
|
||||||
|
{"Validation.Warning", func() { state.validation.Warning(path, "message") }},
|
||||||
|
} {
|
||||||
|
t.Run(operation.name, func(t *testing.T) {
|
||||||
|
defer func() {
|
||||||
|
if got := recover(); got != "configkit: callback context is no longer active" {
|
||||||
|
t.Errorf("panic = %v; want fixed closed-context failure", got)
|
||||||
|
}
|
||||||
|
if target != 0 {
|
||||||
|
t.Error("inactive helper changed target")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
operation.call()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
52
facade_internal_test.go
Normal file
52
facade_internal_test.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWrappedSourcesPreserveEngineOwnership(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
data := []byte("value: retained\n")
|
||||||
|
source := Bytes("snapshot.yml", data)
|
||||||
|
data[7] = 'X'
|
||||||
|
result, err := engine.Load[config](source.toEngine(), engine.SecretBinding{})
|
||||||
|
if err != nil || result.Config.Value != "retained" {
|
||||||
|
t.Fatalf("snapshotted source = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
readerSource := Reader("reader.yml", strings.NewReader("value: once\n"))
|
||||||
|
first := readerSource
|
||||||
|
if _, err := engine.Load[config](first.toEngine(), engine.SecretBinding{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = engine.Load[config](readerSource.toEngine(), engine.SecretBinding{})
|
||||||
|
var loadError *engine.LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != engine.CodeSourceReaderUsed {
|
||||||
|
t.Fatalf("copied reader source error = %#v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrappedPathsRetainEngineSegmentIntent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
parsed, err := ParsePath("/0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
keyed, indexed := Keys("0"), Path{}.Index(0)
|
||||||
|
if parsed.String() != "/0" || keyed.String() != "/0" || indexed.String() != "/0" {
|
||||||
|
t.Fatalf("path spellings = %q, %q, %q", parsed, keyed, indexed)
|
||||||
|
}
|
||||||
|
if reflect.DeepEqual(parsed.value, keyed.value) || reflect.DeepEqual(parsed.value, indexed.value) || reflect.DeepEqual(keyed.value, indexed.value) {
|
||||||
|
t.Fatal("path wrappers collapsed parsed, mapping-key, and index segments")
|
||||||
|
}
|
||||||
|
}
|
||||||
66
facade_version_test.go
Normal file
66
facade_version_test.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type versionCountingReader struct {
|
||||||
|
*strings.Reader
|
||||||
|
reads int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *versionCountingReader) Read(data []byte) (int, error) {
|
||||||
|
r.reads++
|
||||||
|
return r.Reader.Read(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeRejectsMissingVersionCheckerBeforeReading(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
type configuration struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
}
|
||||||
|
reader := &versionCountingReader{Reader: strings.NewReader("version: v1\n")}
|
||||||
|
source := configkit.Reader("version.yml", reader)
|
||||||
|
var defaults, resolutions, validations, checks int
|
||||||
|
options := configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
|
||||||
|
Version: &configkit.VersionRule{Path: configkit.Keys("version")},
|
||||||
|
Defaults: func() configuration { defaults++; return configuration{} },
|
||||||
|
Resolve: func(*configuration, *configkit.Resolution) error { resolutions++; return nil },
|
||||||
|
Validate: func(*configuration, *configkit.Validation) { validations++ },
|
||||||
|
}}
|
||||||
|
result, err := configkit.Load(source, options)
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if result != nil || !errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("missing checker: result = %v, error = %v", result, err)
|
||||||
|
}
|
||||||
|
diagnostics := loadError.Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeOptionsInvalid {
|
||||||
|
t.Fatalf("diagnostics = %v", diagnostics)
|
||||||
|
}
|
||||||
|
if reader.reads != 0 || defaults != 0 || resolutions != 0 || validations != 0 {
|
||||||
|
t.Fatalf("invalid options performed work: reads=%d defaults=%d resolve=%d validate=%d", reader.reads, defaults, resolutions, validations)
|
||||||
|
}
|
||||||
|
options.Definition.Version.Check = func(version configkit.Version, validation *configkit.Validation) error {
|
||||||
|
checks++
|
||||||
|
if version != (configkit.Version{Present: true, Text: "v1"}) {
|
||||||
|
t.Errorf("version = %#v", version)
|
||||||
|
}
|
||||||
|
validation.Warning(configkit.Keys("version"), "version accepted")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result, err = configkit.Load(source, options)
|
||||||
|
if err != nil || result == nil || result.Config.Version != "v1" {
|
||||||
|
t.Fatalf("reuse with checker: result = %v, error = %v", result, err)
|
||||||
|
}
|
||||||
|
if checks != 1 || defaults != 1 || resolutions != 1 || validations != 1 {
|
||||||
|
t.Fatalf("callback counts: check=%d defaults=%d resolve=%d validate=%d", checks, defaults, resolutions, validations)
|
||||||
|
}
|
||||||
|
diagnostics = result.Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeVersionWarning {
|
||||||
|
t.Fatalf("version callback diagnostics = %v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,15 @@ func TestDependencyImportBoundary(t *testing.T) {
|
|||||||
if strings.HasPrefix(importPath, "github.com/google/go-cmp/") && !strings.HasSuffix(path, "_test.go") {
|
if strings.HasPrefix(importPath, "github.com/google/go-cmp/") && !strings.HasSuffix(path, "_test.go") {
|
||||||
t.Errorf("%s imports test-only dependency %s", path, importPath)
|
t.Errorf("%s imports test-only dependency %s", path, importPath)
|
||||||
}
|
}
|
||||||
|
production := !strings.HasSuffix(path, "_test.go")
|
||||||
|
rootProduction := production && filepath.Dir(path) == "."
|
||||||
|
internalProduction := production && strings.HasPrefix(filepath.ToSlash(path), "internal/")
|
||||||
|
if rootProduction && strings.HasPrefix(importPath, "gitea.maximumdirect.net/eric/ConfigKit/internal/") && importPath != "gitea.maximumdirect.net/eric/ConfigKit/internal/engine" {
|
||||||
|
t.Errorf("%s bypasses the engine boundary through %s", path, importPath)
|
||||||
|
}
|
||||||
|
if internalProduction && importPath == "gitea.maximumdirect.net/eric/ConfigKit" {
|
||||||
|
t.Errorf("%s imports the public root package", path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|||||||
678
imports.go
678
imports.go
@@ -1,678 +0,0 @@
|
|||||||
package configkit
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
|
||||||
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
maxImportDepth = 64
|
|
||||||
maxImportSources = 1024
|
|
||||||
maxGlobEntries = 1_000_000
|
|
||||||
)
|
|
||||||
|
|
||||||
type importLimits struct {
|
|
||||||
depth int
|
|
||||||
sources int
|
|
||||||
bytes int64
|
|
||||||
nodes int
|
|
||||||
entries int
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultImportLimits() importLimits {
|
|
||||||
return importLimits{
|
|
||||||
depth: maxImportDepth, sources: maxImportSources,
|
|
||||||
bytes: maxTotalBytes, nodes: maxDocumentNodes, entries: maxGlobEntries,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type importDeclaration struct {
|
|
||||||
path string
|
|
||||||
glob string
|
|
||||||
optional bool
|
|
||||||
location Location
|
|
||||||
}
|
|
||||||
|
|
||||||
type importRoute struct {
|
|
||||||
canonical string
|
|
||||||
locations []Location
|
|
||||||
}
|
|
||||||
|
|
||||||
type importTraversal struct {
|
|
||||||
rootPath string
|
|
||||||
root *os.Root
|
|
||||||
limits importLimits
|
|
||||||
rule *VersionRule
|
|
||||||
|
|
||||||
documents []*document.Node
|
|
||||||
sources []SourceInfo
|
|
||||||
active map[string]int
|
|
||||||
visited map[string]importRoute
|
|
||||||
stack []importRoute
|
|
||||||
usedBytes int64
|
|
||||||
usedNodes int
|
|
||||||
usedEntries int
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadImportedDocuments(source Source, context *FilesystemContext, main parsedSource, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
|
|
||||||
rootPath, basePath, mainPath, diagnostic := importFilesystem(source, context, main)
|
|
||||||
if diagnostic != nil {
|
|
||||||
return nil, sourceInfos(main.source), []Diagnostic{*diagnostic}
|
|
||||||
}
|
|
||||||
root, err := os.OpenRoot(rootPath)
|
|
||||||
if err != nil {
|
|
||||||
diagnostic := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
|
||||||
diagnostic.Primary = nil
|
|
||||||
return nil, sourceInfos(main.source), []Diagnostic{diagnostic}
|
|
||||||
}
|
|
||||||
|
|
||||||
mainInfo := cloneSourceInfo(*main.source)
|
|
||||||
if mainPath != "" {
|
|
||||||
mainInfo.Path = mainPath
|
|
||||||
}
|
|
||||||
state := importTraversal{
|
|
||||||
rootPath: rootPath, root: root, limits: limits, rule: rule,
|
|
||||||
documents: make([]*document.Node, 0, 1),
|
|
||||||
sources: []SourceInfo{mainInfo},
|
|
||||||
active: make(map[string]int), visited: make(map[string]importRoute),
|
|
||||||
usedBytes: main.bytes, usedNodes: countInputNodes(main.document),
|
|
||||||
}
|
|
||||||
if diagnostic := state.checkInitialLimits(); diagnostic != nil {
|
|
||||||
_ = root.Close()
|
|
||||||
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
|
|
||||||
}
|
|
||||||
|
|
||||||
mainIdentity := mainPath
|
|
||||||
if mainIdentity == "" {
|
|
||||||
mainIdentity = "<selected-source>"
|
|
||||||
}
|
|
||||||
mainRoute := importRoute{canonical: mainIdentity}
|
|
||||||
state.active[mainIdentity] = 0
|
|
||||||
state.visited[mainIdentity] = mainRoute
|
|
||||||
state.stack = append(state.stack, mainRoute)
|
|
||||||
body, declarations, diagnostics := splitImportDirectives(main.document)
|
|
||||||
if len(diagnostics) == 0 {
|
|
||||||
state.documents = append(state.documents, body)
|
|
||||||
diagnostics = state.walkDeclarations(basePath, declarations, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
closeErr := root.Close()
|
|
||||||
if len(diagnostics) != 0 {
|
|
||||||
return nil, cloneSourceInfos(state.sources), diagnostics
|
|
||||||
}
|
|
||||||
if closeErr != nil {
|
|
||||||
return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)}
|
|
||||||
}
|
|
||||||
composed, diagnostics := composeDocuments(state.documents...)
|
|
||||||
if composed != nil {
|
|
||||||
composed.inputBytes = state.usedBytes
|
|
||||||
composed.inputNodes = state.usedNodes
|
|
||||||
}
|
|
||||||
return composed, cloneSourceInfos(state.sources), diagnostics
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource) *Diagnostic {
|
|
||||||
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
|
|
||||||
if diagnostic != nil {
|
|
||||||
return diagnostic
|
|
||||||
}
|
|
||||||
root, err := os.OpenRoot(rootPath)
|
|
||||||
if err != nil {
|
|
||||||
value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
|
||||||
value.Primary = nil
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
if err := root.Close(); err != nil {
|
|
||||||
value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic {
|
|
||||||
switch source.kind {
|
|
||||||
case sourceNone:
|
|
||||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "NoSource cannot use filesystem context")}
|
|
||||||
case sourceFile:
|
|
||||||
if context.BaseDir != "" {
|
|
||||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")}
|
|
||||||
}
|
|
||||||
case sourceBytes, sourceReader:
|
|
||||||
if context.BaseDir == "" || context.Root == "" {
|
|
||||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "byte and reader filesystem context requires base and root directories")}
|
|
||||||
}
|
|
||||||
if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) {
|
|
||||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "byte and reader filesystem base and root directories must be absolute")}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func importFilesystem(source Source, context *FilesystemContext, main parsedSource) (rootPath, basePath, mainPath string, diagnostic *Diagnostic) {
|
|
||||||
if main.source == nil || main.document == nil {
|
|
||||||
value := importDiagnostic(CodeImportInvalid, "imports require a selected configuration source", nil)
|
|
||||||
return "", "", "", &value
|
|
||||||
}
|
|
||||||
|
|
||||||
if source.kind == sourceFile {
|
|
||||||
if context != nil && context.BaseDir != "" {
|
|
||||||
value := optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")
|
|
||||||
return "", "", "", &value
|
|
||||||
}
|
|
||||||
canonical, err := canonicalExistingPath(source.path)
|
|
||||||
if err != nil {
|
|
||||||
value := importDiagnostic(CodeImportInvalid, "selected source path could not be canonicalized", nil)
|
|
||||||
return "", "", "", &value
|
|
||||||
}
|
|
||||||
mainPath = canonical
|
|
||||||
basePath = filepath.Dir(canonical)
|
|
||||||
rootPath = basePath
|
|
||||||
if context != nil && context.Root != "" {
|
|
||||||
rootPath, err = canonicalExistingPath(context.Root)
|
|
||||||
if err != nil {
|
|
||||||
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
|
||||||
return "", "", "", &value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if context == nil || context.BaseDir == "" || context.Root == "" {
|
|
||||||
value := optionDiagnostic(CodeOptionsInvalid, "byte and reader imports require absolute base and root directories")
|
|
||||||
return "", "", "", &value
|
|
||||||
}
|
|
||||||
if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) {
|
|
||||||
value := optionDiagnostic(CodeOptionsInvalid, "byte and reader import base and root directories must be absolute")
|
|
||||||
return "", "", "", &value
|
|
||||||
}
|
|
||||||
var err error
|
|
||||||
basePath, err = canonicalExistingPath(context.BaseDir)
|
|
||||||
if err != nil {
|
|
||||||
value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil)
|
|
||||||
return "", "", "", &value
|
|
||||||
}
|
|
||||||
rootPath, err = canonicalExistingPath(context.Root)
|
|
||||||
if err != nil {
|
|
||||||
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
|
||||||
return "", "", "", &value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !containedPath(rootPath, basePath) || (mainPath != "" && !containedPath(rootPath, mainPath)) {
|
|
||||||
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
|
|
||||||
return "", "", "", &value
|
|
||||||
}
|
|
||||||
for _, candidate := range []string{rootPath, basePath} {
|
|
||||||
info, err := os.Stat(candidate)
|
|
||||||
if err != nil || !info.IsDir() {
|
|
||||||
value := importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)
|
|
||||||
return "", "", "", &value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rootPath, basePath, mainPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func canonicalExistingPath(name string) (string, error) {
|
|
||||||
absolute, err := filepath.Abs(name)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return filepath.EvalSymlinks(filepath.Clean(absolute))
|
|
||||||
}
|
|
||||||
|
|
||||||
func containedPath(root, target string) bool {
|
|
||||||
relative, err := filepath.Rel(root, target)
|
|
||||||
if err != nil || filepath.IsAbs(relative) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *importTraversal) checkInitialLimits() *Diagnostic {
|
|
||||||
if s.limits.sources < 1 {
|
|
||||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
if s.usedBytes > s.limits.bytes {
|
|
||||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate byte limit", nil)
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
if s.usedNodes > s.limits.nodes {
|
|
||||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *importTraversal) walkDeclarations(basePath string, declarations []importDeclaration, depth int) []Diagnostic {
|
|
||||||
for _, declaration := range declarations {
|
|
||||||
if declaration.glob != "" {
|
|
||||||
matches, diagnostics := s.expandImportGlob(basePath, declaration)
|
|
||||||
if len(diagnostics) != 0 {
|
|
||||||
return diagnostics
|
|
||||||
}
|
|
||||||
if len(matches) == 0 {
|
|
||||||
if declaration.optional {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
location := declaration.location
|
|
||||||
return []Diagnostic{importDiagnostic(CodeImportMissing, "required import glob matched no files", &location)}
|
|
||||||
}
|
|
||||||
for _, match := range matches {
|
|
||||||
matched := declaration
|
|
||||||
matched.path = match
|
|
||||||
matched.glob = ""
|
|
||||||
if diagnostics := s.visitPath(basePath, matched, depth+1); len(diagnostics) != 0 {
|
|
||||||
return diagnostics
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if diagnostics := s.visitPath(basePath, declaration, depth+1); len(diagnostics) != 0 {
|
|
||||||
return diagnostics
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *importTraversal) visitPath(basePath string, declaration importDeclaration, depth int) []Diagnostic {
|
|
||||||
location := declaration.location
|
|
||||||
if depth > s.limits.depth {
|
|
||||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the active import-depth limit", &location)}
|
|
||||||
}
|
|
||||||
if !validImportPath(declaration.path) {
|
|
||||||
return []Diagnostic{importDiagnostic(CodeImportInvalid, "import path must be a nonempty slash-relative path without a network scheme", &location)}
|
|
||||||
}
|
|
||||||
|
|
||||||
candidate := filepath.Join(basePath, filepath.FromSlash(path.Clean(declaration.path)))
|
|
||||||
if !containedPath(s.rootPath, filepath.Clean(candidate)) {
|
|
||||||
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target is outside the configuration root", &location)}
|
|
||||||
}
|
|
||||||
canonical, err := canonicalExistingPath(candidate)
|
|
||||||
if err != nil {
|
|
||||||
if declaration.optional && errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
code := CodeImportInvalid
|
|
||||||
message := "import target could not be canonicalized"
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
code, message = CodeImportMissing, "required import target does not exist"
|
|
||||||
} else if errors.Is(err, fs.ErrPermission) {
|
|
||||||
message = "import target cannot be accessed"
|
|
||||||
}
|
|
||||||
return []Diagnostic{importDiagnostic(code, message, &location)}
|
|
||||||
}
|
|
||||||
if !containedPath(s.rootPath, canonical) {
|
|
||||||
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target resolves outside the configuration root", &location)}
|
|
||||||
}
|
|
||||||
|
|
||||||
currentRoute := s.currentRoute(location)
|
|
||||||
if activeIndex, ok := s.active[canonical]; ok {
|
|
||||||
cycle := append([]importRoute(nil), s.stack[activeIndex:]...)
|
|
||||||
return []Diagnostic{importCycleDiagnostic(canonical, location, cycle)}
|
|
||||||
}
|
|
||||||
if previous, ok := s.visited[canonical]; ok {
|
|
||||||
return []Diagnostic{importRepeatedDiagnostic(location, previous, currentRoute)}
|
|
||||||
}
|
|
||||||
if len(s.sources)+1 > s.limits.sources {
|
|
||||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", &location)}
|
|
||||||
}
|
|
||||||
|
|
||||||
relative, err := filepath.Rel(s.rootPath, canonical)
|
|
||||||
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
|
||||||
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target could not be addressed inside the configuration root", &location)}
|
|
||||||
}
|
|
||||||
sourceID := "source-" + strconv.Itoa(len(s.sources)+1)
|
|
||||||
info := SourceInfo{ID: sourceID, Label: declaration.path, Path: canonical, Layer: LayerBase, ImportedFrom: &location}
|
|
||||||
s.sources = append(s.sources, info)
|
|
||||||
data, skipped, diagnostic := s.readImport(relative, sourceID, declaration.optional)
|
|
||||||
if diagnostic != nil {
|
|
||||||
return []Diagnostic{*diagnostic}
|
|
||||||
}
|
|
||||||
if skipped {
|
|
||||||
s.sources = s.sources[:len(s.sources)-1]
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
root, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{
|
|
||||||
Depth: maxDocumentDepth, Nodes: maxDocumentNodes,
|
|
||||||
})
|
|
||||||
if len(issues) != 0 {
|
|
||||||
return importParseDiagnostics(sourceID, issues)
|
|
||||||
}
|
|
||||||
nodeCount := countInputNodes(root)
|
|
||||||
if s.usedNodes+nodeCount > s.limits.nodes {
|
|
||||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)}
|
|
||||||
}
|
|
||||||
s.usedNodes += nodeCount
|
|
||||||
|
|
||||||
body, declarations, diagnostics := splitImportDirectives(root)
|
|
||||||
if len(diagnostics) != 0 {
|
|
||||||
return diagnostics
|
|
||||||
}
|
|
||||||
if versionNode := fragmentVersionClaim(body, s.rule); versionNode != nil {
|
|
||||||
versionLocation := publicLocation(versionNode.Location)
|
|
||||||
return []Diagnostic{{
|
|
||||||
Code: CodeImportVersion, Severity: SeverityError, Path: s.rule.Path.clone(),
|
|
||||||
Message: "imported fragments cannot contribute to the schema-version path", Primary: &versionLocation,
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
route := importRoute{canonical: canonical, locations: currentRoute.locations}
|
|
||||||
s.documents = append(s.documents, body)
|
|
||||||
s.active[canonical] = len(s.stack)
|
|
||||||
s.visited[canonical] = route
|
|
||||||
s.stack = append(s.stack, route)
|
|
||||||
diagnostics = s.walkDeclarations(filepath.Dir(canonical), declarations, depth)
|
|
||||||
delete(s.active, canonical)
|
|
||||||
s.stack = s.stack[:len(s.stack)-1]
|
|
||||||
return diagnostics
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *importTraversal) readImport(relative, sourceID string, optional bool) ([]byte, bool, *Diagnostic) {
|
|
||||||
remaining := s.limits.bytes - s.usedBytes
|
|
||||||
limit := min(int64(maxSourceBytes), remaining)
|
|
||||||
if limit < 0 {
|
|
||||||
limit = 0
|
|
||||||
}
|
|
||||||
preflight, err := s.root.Stat(relative)
|
|
||||||
if err != nil {
|
|
||||||
if optional && errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return nil, true, nil
|
|
||||||
}
|
|
||||||
value := classifySourceError(err, sourceID)
|
|
||||||
return nil, false, &value
|
|
||||||
}
|
|
||||||
if !preflight.Mode().IsRegular() {
|
|
||||||
value := sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")
|
|
||||||
return nil, false, &value
|
|
||||||
}
|
|
||||||
file, err := s.root.Open(relative)
|
|
||||||
if err != nil {
|
|
||||||
if optional && errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return nil, true, nil
|
|
||||||
}
|
|
||||||
value := classifySourceError(err, sourceID)
|
|
||||||
return nil, false, &value
|
|
||||||
}
|
|
||||||
defer func() { _ = file.Close() }()
|
|
||||||
info, err := file.Stat()
|
|
||||||
if err != nil {
|
|
||||||
value := classifySourceError(err, sourceID)
|
|
||||||
return nil, false, &value
|
|
||||||
}
|
|
||||||
if !info.Mode().IsRegular() {
|
|
||||||
value := sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")
|
|
||||||
return nil, false, &value
|
|
||||||
}
|
|
||||||
if info.Size() > limit {
|
|
||||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit", &Location{SourceID: sourceID})
|
|
||||||
return nil, false, &value
|
|
||||||
}
|
|
||||||
data, err := io.ReadAll(io.LimitReader(file, limit+1))
|
|
||||||
if err != nil {
|
|
||||||
value := classifySourceError(err, sourceID)
|
|
||||||
return nil, false, &value
|
|
||||||
}
|
|
||||||
if int64(len(data)) > limit {
|
|
||||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit", &Location{SourceID: sourceID})
|
|
||||||
return nil, false, &value
|
|
||||||
}
|
|
||||||
s.usedBytes += int64(len(data))
|
|
||||||
return data, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *importTraversal) currentRoute(location Location) importRoute {
|
|
||||||
locations := make([]Location, 0, len(s.stack)+1)
|
|
||||||
for _, route := range s.stack {
|
|
||||||
if len(route.locations) != 0 {
|
|
||||||
locations = append(locations, route.locations[len(route.locations)-1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
locations = append(locations, location)
|
|
||||||
return importRoute{locations: locations}
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitImportDirectives(root *document.Node) (*document.Node, []importDeclaration, []Diagnostic) {
|
|
||||||
body := document.Clone(root)
|
|
||||||
var declarations []importDeclaration
|
|
||||||
directiveIndex := -1
|
|
||||||
for index, entry := range body.Mapping {
|
|
||||||
if entry.Key != "_configkit" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
directiveIndex = index
|
|
||||||
parsed, diagnostics := parseImportDirective(entry.Value, Keys("_configkit"))
|
|
||||||
if len(diagnostics) != 0 {
|
|
||||||
return nil, nil, diagnostics
|
|
||||||
}
|
|
||||||
declarations = parsed
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if directiveIndex >= 0 {
|
|
||||||
body.Mapping = append(body.Mapping[:directiveIndex], body.Mapping[directiveIndex+1:]...)
|
|
||||||
}
|
|
||||||
if diagnostics := nestedDirectiveDiagnostics(body, Path{}); len(diagnostics) != 0 {
|
|
||||||
return nil, nil, diagnostics
|
|
||||||
}
|
|
||||||
return body, declarations, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseImportDirective(node *document.Node, directivePath Path) ([]importDeclaration, []Diagnostic) {
|
|
||||||
if node.Kind != document.Mapping {
|
|
||||||
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, directivePath, "configuration directive must be a mapping", node)}
|
|
||||||
}
|
|
||||||
var declarations []importDeclaration
|
|
||||||
for _, entry := range node.Mapping {
|
|
||||||
fieldPath := directivePath.Key(entry.Key)
|
|
||||||
if entry.Key != "imports" {
|
|
||||||
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "configuration directive field is not recognized", entry.Value)}
|
|
||||||
}
|
|
||||||
if entry.Value.Kind != document.Sequence {
|
|
||||||
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "imports must be a sequence", entry.Value)}
|
|
||||||
}
|
|
||||||
for index, item := range entry.Value.Sequence {
|
|
||||||
declaration, diagnostics := parseImportDeclaration(item, fieldPath.Index(index))
|
|
||||||
if len(diagnostics) != 0 {
|
|
||||||
return nil, diagnostics
|
|
||||||
}
|
|
||||||
declarations = append(declarations, declaration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return declarations, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseImportDeclaration(node *document.Node, declarationPath Path) (importDeclaration, []Diagnostic) {
|
|
||||||
if node.Kind != document.Mapping {
|
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration must be a mapping", node)}
|
|
||||||
}
|
|
||||||
declaration := importDeclaration{location: publicLocation(node.Location)}
|
|
||||||
seenTarget := false
|
|
||||||
for _, entry := range node.Mapping {
|
|
||||||
fieldPath := declarationPath.Key(entry.Key)
|
|
||||||
switch entry.Key {
|
|
||||||
case "path", "glob":
|
|
||||||
if seenTarget || entry.Value.Kind != document.String || entry.Value.Text == "" || strings.TrimSpace(entry.Value.Text) != entry.Value.Text {
|
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration requires exactly one nonempty path or glob string", entry.Value)}
|
|
||||||
}
|
|
||||||
seenTarget = true
|
|
||||||
declaration.location = publicLocation(entry.Value.Location)
|
|
||||||
if entry.Key == "path" {
|
|
||||||
declaration.path = entry.Value.Text
|
|
||||||
} else {
|
|
||||||
declaration.glob = entry.Value.Text
|
|
||||||
}
|
|
||||||
case "optional":
|
|
||||||
if entry.Value.Kind != document.Boolean {
|
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "optional must be a boolean", entry.Value)}
|
|
||||||
}
|
|
||||||
declaration.optional = entry.Value.Text == "true"
|
|
||||||
default:
|
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not recognized", entry.Value)}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !seenTarget {
|
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration requires exactly one path or glob", node)}
|
|
||||||
}
|
|
||||||
return declaration, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func nestedDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic {
|
|
||||||
if node.Kind == document.Mapping {
|
|
||||||
for _, entry := range node.Mapping {
|
|
||||||
childPath := current.Key(entry.Key)
|
|
||||||
if entry.Key == "_configkit" {
|
|
||||||
location := publicLocation(entry.KeyLocation)
|
|
||||||
return []Diagnostic{{Code: CodeImportInvalid, Severity: SeverityError, Path: childPath, Message: "configuration directives are permitted only at the document root", Primary: &location}}
|
|
||||||
}
|
|
||||||
if diagnostics := nestedDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 {
|
|
||||||
return diagnostics
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if node.Kind == document.Sequence {
|
|
||||||
for index, child := range node.Sequence {
|
|
||||||
if diagnostics := nestedDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 {
|
|
||||||
return diagnostics
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func fragmentVersionClaim(root *document.Node, rule *VersionRule) *document.Node {
|
|
||||||
if rule == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
current := root
|
|
||||||
if len(rule.Path.segments) == 0 {
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
for _, segment := range rule.Path.segments {
|
|
||||||
if current.Kind != document.Mapping {
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
if segment.kind == pathSegmentIndex {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
key := segment.key
|
|
||||||
index := documentEntryIndex(current.Mapping, key)
|
|
||||||
if index < 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
current = current.Mapping[index].Value
|
|
||||||
}
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
|
|
||||||
func countInputNodes(node *document.Node) int {
|
|
||||||
if node == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
count := 1
|
|
||||||
if node.Kind == document.Mapping {
|
|
||||||
count += len(node.Mapping)
|
|
||||||
for _, entry := range node.Mapping {
|
|
||||||
count += countInputNodes(entry.Value)
|
|
||||||
}
|
|
||||||
} else if node.Kind == document.Sequence {
|
|
||||||
for _, child := range node.Sequence {
|
|
||||||
count += countInputNodes(child)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
func validImportPath(value string) bool {
|
|
||||||
if value == "" || strings.TrimSpace(value) != value || path.IsAbs(value) || strings.HasPrefix(value, "//") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return !hasImportScheme(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasImportScheme(value string) bool {
|
|
||||||
colon := strings.IndexByte(value, ':')
|
|
||||||
slash := strings.IndexByte(value, '/')
|
|
||||||
if colon <= 0 || (slash >= 0 && slash < colon) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for index := 0; index < colon; index++ {
|
|
||||||
character := value[index]
|
|
||||||
if index == 0 {
|
|
||||||
if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') &&
|
|
||||||
(character < '0' || character > '9') && character != '+' && character != '-' && character != '.' {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func sourceInfos(source *SourceInfo) []SourceInfo {
|
|
||||||
if source == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []SourceInfo{cloneSourceInfo(*source)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func importParseDiagnostics(sourceID string, issues []yamladapter.Issue) []Diagnostic {
|
|
||||||
diagnostics := make([]Diagnostic, len(issues))
|
|
||||||
for index, issue := range issues {
|
|
||||||
diagnostics[index] = Diagnostic{
|
|
||||||
Code: Code(issue.Code), Severity: SeverityError, Message: issue.Message,
|
|
||||||
Primary: &Location{SourceID: sourceID, Line: issue.Line, Column: issue.Column},
|
|
||||||
}
|
|
||||||
for _, related := range issue.Related {
|
|
||||||
diagnostics[index].Related = append(diagnostics[index].Related, publicLocation(related))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return diagnostics
|
|
||||||
}
|
|
||||||
|
|
||||||
func importDiagnostic(code Code, message string, location *Location) Diagnostic {
|
|
||||||
return Diagnostic{Code: code, Severity: SeverityError, Message: message, Primary: cloneLocation(location)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func nodeImportDiagnostic(code Code, path Path, message string, node *document.Node) Diagnostic {
|
|
||||||
location := publicLocation(node.Location)
|
|
||||||
return Diagnostic{Code: code, Severity: SeverityError, Path: path, Message: message, Primary: &location}
|
|
||||||
}
|
|
||||||
|
|
||||||
func importCycleDiagnostic(target string, location Location, cycle []importRoute) Diagnostic {
|
|
||||||
paths := make([]string, 0, len(cycle)+1)
|
|
||||||
related := make([]Location, 0, len(cycle))
|
|
||||||
for _, route := range cycle {
|
|
||||||
paths = append(paths, route.canonical)
|
|
||||||
if len(route.locations) != 0 {
|
|
||||||
related = append(related, route.locations[len(route.locations)-1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
paths = append(paths, target)
|
|
||||||
return Diagnostic{
|
|
||||||
Code: CodeImportCycle, Severity: SeverityError,
|
|
||||||
Message: "import cycle detected: " + strings.Join(paths, " -> "),
|
|
||||||
Primary: &location, Related: related,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func importRepeatedDiagnostic(location Location, previous, current importRoute) Diagnostic {
|
|
||||||
related := append([]Location(nil), previous.locations...)
|
|
||||||
if len(current.locations) > 1 {
|
|
||||||
related = append(related, current.locations[:len(current.locations)-1]...)
|
|
||||||
}
|
|
||||||
return Diagnostic{
|
|
||||||
Code: CodeImportRepeated, Severity: SeverityError,
|
|
||||||
Message: "configuration source is included by more than one import route",
|
|
||||||
Primary: &location, Related: related,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
70
inspection.go
Normal file
70
inspection.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValueSnapshot is a detached, deterministic YAML representation of one value.
|
||||||
|
// Present distinguishes absence or deletion from an explicit null value.
|
||||||
|
type ValueSnapshot struct {
|
||||||
|
Present bool
|
||||||
|
Redacted bool
|
||||||
|
YAML string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValueChange describes one explicit definition or audited domain operation.
|
||||||
|
type ValueChange struct {
|
||||||
|
Before ValueSnapshot
|
||||||
|
After ValueSnapshot
|
||||||
|
Origin Origin
|
||||||
|
Reasons []string
|
||||||
|
Dependencies []Path
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretReferenceInfo describes a successfully resolved effective reference.
|
||||||
|
// Name is the environment-variable name, never its value.
|
||||||
|
type SecretReferenceInfo struct {
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explanation is a detached safe account of one effective or historical path.
|
||||||
|
type Explanation struct {
|
||||||
|
Path Path
|
||||||
|
Effective ValueSnapshot
|
||||||
|
Origin Origin
|
||||||
|
History []ValueChange
|
||||||
|
SecretReference *SecretReferenceInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explain returns safe effective and chronological values for a known final or
|
||||||
|
// historical path. Unknown and malformed paths return false.
|
||||||
|
func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
||||||
|
if r == nil {
|
||||||
|
return Explanation{}, false
|
||||||
|
}
|
||||||
|
value, ok := r.inspection.Explain(toEnginePath(path))
|
||||||
|
return fromEngineExplanation(value), ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteEffective writes deterministic redacted YAML from the frozen resolved
|
||||||
|
// snapshot. The output is intended for inspection and is not promised reloadable.
|
||||||
|
// Writer errors, including short writes, are returned and may leave partial output.
|
||||||
|
func (r *Result[T]) WriteEffective(output io.Writer) error {
|
||||||
|
if r == nil {
|
||||||
|
var inspection *engine.Inspection
|
||||||
|
return inspection.WriteEffective(output)
|
||||||
|
}
|
||||||
|
return r.inspection.WriteEffective(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteExplanation writes a deterministic human-readable rendering of Explain.
|
||||||
|
// Unknown paths produce no output. Writer failures may leave partial output.
|
||||||
|
func (r *Result[T]) WriteExplanation(output io.Writer, path Path) error {
|
||||||
|
if r == nil {
|
||||||
|
var inspection *engine.Inspection
|
||||||
|
return inspection.WriteExplanation(output, toEnginePath(path))
|
||||||
|
}
|
||||||
|
return r.inspection.WriteExplanation(output, toEnginePath(path))
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
159
internal/engine/clone.go
Normal file
159
internal/engine/clone.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding"
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
|
)
|
||||||
|
|
||||||
|
// secretCloneMode distinguishes fresh unresolved references from publication of
|
||||||
|
// already validated private state, which must not trigger another lookup.
|
||||||
|
type secretCloneMode uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
cloneUnresolvedSecrets secretCloneMode = iota
|
||||||
|
cloneValidatedSecrets
|
||||||
|
)
|
||||||
|
|
||||||
|
func cloneConfiguration(schema *schemaDescriptor, value reflect.Value, limits projectionLimits) (reflect.Value, *projectionIssue) {
|
||||||
|
return cloneConfigurationWithMode(schema, value, limits, cloneUnresolvedSecrets)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneConfigurationWithMode(schema *schemaDescriptor, value reflect.Value, limits projectionLimits, mode secretCloneMode) (reflect.Value, *projectionIssue) {
|
||||||
|
if schema == nil || schema.root == nil || !value.IsValid() || value.Type() != schema.root.typeOf {
|
||||||
|
return reflect.Value{}, &projectionIssue{code: CodeSchemaUnsupportedType, message: "configuration value does not match its schema"}
|
||||||
|
}
|
||||||
|
operation := valueOperation{schema: schema, limits: limits, active: make(map[valueVisit]bool), cloneMode: mode}
|
||||||
|
return operation.clone(value, schema.root, Path{}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *valueOperation) clone(value reflect.Value, descriptor *typeDescriptor, path Path, depth int) (reflect.Value, *projectionIssue) {
|
||||||
|
if issue := o.checkDepth(path, depth); issue != nil {
|
||||||
|
return reflect.Value{}, issue
|
||||||
|
}
|
||||||
|
if descriptor.kind == descriptorPointer {
|
||||||
|
if value.IsNil() {
|
||||||
|
return reflect.Zero(value.Type()), nil
|
||||||
|
}
|
||||||
|
leave, issue := o.enter(value, path)
|
||||||
|
if issue != nil {
|
||||||
|
return reflect.Value{}, issue
|
||||||
|
}
|
||||||
|
defer leave()
|
||||||
|
child, issue := o.clone(value.Elem(), descriptor.element, path, depth)
|
||||||
|
if issue != nil {
|
||||||
|
return reflect.Value{}, issue
|
||||||
|
}
|
||||||
|
result := reflect.New(value.Type().Elem())
|
||||||
|
result.Elem().Set(child)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch descriptor.kind {
|
||||||
|
case descriptorStruct:
|
||||||
|
result := reflect.New(value.Type()).Elem()
|
||||||
|
for _, member := range descriptor.members {
|
||||||
|
memberPath, memberDepth := path, depth
|
||||||
|
if !member.inline {
|
||||||
|
memberPath, memberDepth = path.Key(member.name), depth+1
|
||||||
|
}
|
||||||
|
cloned, issue := o.clone(value.Field(member.index), member.descriptor, memberPath, memberDepth)
|
||||||
|
if issue != nil {
|
||||||
|
return reflect.Value{}, issue
|
||||||
|
}
|
||||||
|
result.Field(member.index).Set(cloned)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
case descriptorMap:
|
||||||
|
if value.IsNil() {
|
||||||
|
return reflect.Zero(value.Type()), nil
|
||||||
|
}
|
||||||
|
leave, issue := o.enter(value, path)
|
||||||
|
if issue != nil {
|
||||||
|
return reflect.Value{}, issue
|
||||||
|
}
|
||||||
|
defer leave()
|
||||||
|
result := reflect.MakeMapWithSize(value.Type(), value.Len())
|
||||||
|
for _, key := range value.MapKeys() {
|
||||||
|
cloned, issue := o.clone(value.MapIndex(key), descriptor.element, path.Key(key.String()), depth+1)
|
||||||
|
if issue != nil {
|
||||||
|
return reflect.Value{}, issue
|
||||||
|
}
|
||||||
|
result.SetMapIndex(key, cloned)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
case descriptorSlice:
|
||||||
|
if value.IsNil() {
|
||||||
|
return reflect.Zero(value.Type()), nil
|
||||||
|
}
|
||||||
|
leave, issue := o.enter(value, path)
|
||||||
|
if issue != nil {
|
||||||
|
return reflect.Value{}, issue
|
||||||
|
}
|
||||||
|
defer leave()
|
||||||
|
result := reflect.MakeSlice(value.Type(), value.Len(), value.Len())
|
||||||
|
for i := 0; i < value.Len(); i++ {
|
||||||
|
cloned, issue := o.clone(value.Index(i), descriptor.element, path.Index(i), depth+1)
|
||||||
|
if issue != nil {
|
||||||
|
return reflect.Value{}, issue
|
||||||
|
}
|
||||||
|
result.Index(i).Set(cloned)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
case descriptorArray:
|
||||||
|
result := reflect.New(value.Type()).Elem()
|
||||||
|
for i := 0; i < value.Len(); i++ {
|
||||||
|
cloned, issue := o.clone(value.Index(i), descriptor.element, path.Index(i), depth+1)
|
||||||
|
if issue != nil {
|
||||||
|
return reflect.Value{}, issue
|
||||||
|
}
|
||||||
|
result.Index(i).Set(cloned)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
case descriptorDynamic:
|
||||||
|
if value.IsNil() {
|
||||||
|
return reflect.Zero(value.Type()), nil
|
||||||
|
}
|
||||||
|
concrete := value.Elem()
|
||||||
|
dynamic, issue := o.dynamicDescriptor(concrete.Type(), path)
|
||||||
|
if issue != nil {
|
||||||
|
return reflect.Value{}, issue
|
||||||
|
}
|
||||||
|
cloned, issue := o.clone(concrete, dynamic, path, depth)
|
||||||
|
if issue != nil {
|
||||||
|
return reflect.Value{}, issue
|
||||||
|
}
|
||||||
|
result := reflect.New(value.Type()).Elem()
|
||||||
|
result.Set(cloned)
|
||||||
|
return result, nil
|
||||||
|
case descriptorSecret:
|
||||||
|
result := reflect.New(value.Type()).Elem()
|
||||||
|
reference := o.schema.secrets.Read(value)
|
||||||
|
if o.cloneMode == cloneValidatedSecrets {
|
||||||
|
result.Set(value)
|
||||||
|
} else if reference.Specified {
|
||||||
|
result.Set(o.schema.secrets.Make(SecretState{Name: reference.Name, Specified: true}))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
case descriptorText:
|
||||||
|
text, issue := marshalCanonicalText(value, path)
|
||||||
|
if issue != nil {
|
||||||
|
return reflect.Value{}, issue
|
||||||
|
}
|
||||||
|
result := reflect.New(value.Type()).Elem()
|
||||||
|
unmarshaler := result.Addr().Interface().(encoding.TextUnmarshaler)
|
||||||
|
if err := unmarshaler.UnmarshalText([]byte(text)); err != nil {
|
||||||
|
return reflect.Value{}, &projectionIssue{code: CodeSchemaCodec, path: path.clone(), message: "custom text scalar could not be reconstructed"}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
case descriptorFloat:
|
||||||
|
if math.IsNaN(value.Float()) || math.IsInf(value.Float(), 0) {
|
||||||
|
return reflect.Value{}, &projectionIssue{code: CodeSchemaNonFinite, path: path.clone(), message: "configuration float must be finite"}
|
||||||
|
}
|
||||||
|
fallthrough
|
||||||
|
default:
|
||||||
|
result := reflect.New(value.Type()).Elem()
|
||||||
|
result.Set(value)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
|
||||||
@@ -15,6 +15,17 @@ type composedDocument struct {
|
|||||||
inputNodes int
|
inputNodes int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cloneComposedDocument detaches mutable composition and provenance state;
|
||||||
|
// history application trees are immutable and retain their existing sharing.
|
||||||
|
func cloneComposedDocument(base *composedDocument) *composedDocument {
|
||||||
|
return &composedDocument{
|
||||||
|
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
|
||||||
|
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
|
||||||
|
applications: cloneHistoryApplications(base.applications),
|
||||||
|
inputBytes: base.inputBytes, inputNodes: base.inputNodes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type compositionClaim struct {
|
type compositionClaim struct {
|
||||||
path Path
|
path Path
|
||||||
kind document.Kind
|
kind document.Kind
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -56,7 +56,7 @@ values: [one, two]
|
|||||||
if len(reversedDiagnostics) != 0 {
|
if len(reversedDiagnostics) != 0 {
|
||||||
t.Fatalf("reversed composeDocuments() diagnostics = %#v", reversedDiagnostics)
|
t.Fatalf("reversed composeDocuments() diagnostics = %#v", reversedDiagnostics)
|
||||||
}
|
}
|
||||||
if deltas := compareDocumentTrees(composed.root, reversed.root); len(deltas) != 0 {
|
if deltas := compareConfigurationValues(composed.root, reversed.root); len(deltas) != 0 {
|
||||||
t.Fatalf("input order changed effective values: %#v", deltas)
|
t.Fatalf("input order changed effective values: %#v", deltas)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -40,7 +40,7 @@ func FuzzCompositionAndOverlay(f *testing.F) {
|
|||||||
if len(reversedDiagnostics) != 0 {
|
if len(reversedDiagnostics) != 0 {
|
||||||
t.Fatalf("reversed composition failed: %#v", reversedDiagnostics)
|
t.Fatalf("reversed composition failed: %#v", reversedDiagnostics)
|
||||||
}
|
}
|
||||||
if differences := compareDocumentTrees(composed.root, reversed.root); len(differences) != 0 {
|
if differences := compareConfigurationValues(composed.root, reversed.root); len(differences) != 0 {
|
||||||
t.Fatalf("composition order changed effective values: %#v", differences)
|
t.Fatalf("composition order changed effective values: %#v", differences)
|
||||||
}
|
}
|
||||||
composed.root.Mapping = append(composed.root.Mapping, document.Entry{Key: "fuzz-detachment"})
|
composed.root.Mapping = append(composed.root.Mapping, document.Entry{Key: "fuzz-detachment"})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding"
|
"encoding"
|
||||||
@@ -34,7 +34,15 @@ type decodedConfiguration struct {
|
|||||||
final *document.Node
|
final *document.Node
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type decodeContext struct {
|
||||||
|
versionPath *Path
|
||||||
|
}
|
||||||
|
|
||||||
func decodeConfiguration(schema *schemaDescriptor, defaults reflect.Value, input *document.Node) (decodedConfiguration, []decodeIssue) {
|
func decodeConfiguration(schema *schemaDescriptor, defaults reflect.Value, input *document.Node) (decodedConfiguration, []decodeIssue) {
|
||||||
|
return decodeConfigurationWithContext(schema, defaults, input, decodeContext{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeConfigurationWithContext(schema *schemaDescriptor, defaults reflect.Value, input *document.Node, context decodeContext) (decodedConfiguration, []decodeIssue) {
|
||||||
if schema == nil || schema.root == nil || !defaults.IsValid() || defaults.Type() != schema.root.typeOf {
|
if schema == nil || schema.root == nil || !defaults.IsValid() || defaults.Type() != schema.root.typeOf {
|
||||||
return decodedConfiguration{}, []decodeIssue{{code: CodeSchemaUnsupportedType, message: "configuration defaults do not match the schema"}}
|
return decodedConfiguration{}, []decodeIssue{{code: CodeSchemaUnsupportedType, message: "configuration defaults do not match the schema"}}
|
||||||
}
|
}
|
||||||
@@ -50,7 +58,7 @@ func decodeConfiguration(schema *schemaDescriptor, defaults reflect.Value, input
|
|||||||
presence := make(map[string]inputPresence)
|
presence := make(map[string]inputPresence)
|
||||||
if input != nil {
|
if input != nil {
|
||||||
collectPresence(input, Path{}, presence)
|
collectPresence(input, Path{}, presence)
|
||||||
decoded, issues := decodeValue(input, working, schema.root, Path{}, schema)
|
decoded, issues := decodeValue(input, working, schema.root, Path{}, schema, context)
|
||||||
if len(issues) != 0 {
|
if len(issues) != 0 {
|
||||||
return decodedConfiguration{}, issues
|
return decodedConfiguration{}, issues
|
||||||
}
|
}
|
||||||
@@ -71,7 +79,7 @@ func decodeConfiguration(schema *schemaDescriptor, defaults reflect.Value, input
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||||
if node.Kind == document.Null {
|
if node.Kind == document.Null {
|
||||||
switch descriptor.kind {
|
switch descriptor.kind {
|
||||||
case descriptorPointer, descriptorMap, descriptorSlice, descriptorDynamic:
|
case descriptorPointer, descriptorMap, descriptorSlice, descriptorDynamic:
|
||||||
@@ -89,7 +97,7 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
|
|||||||
} else {
|
} else {
|
||||||
elementBase = base.Elem()
|
elementBase = base.Elem()
|
||||||
}
|
}
|
||||||
decoded, issues := decodeValue(node, elementBase, descriptor.element, path, schema)
|
decoded, issues := decodeValue(node, elementBase, descriptor.element, path, schema, context)
|
||||||
if len(issues) != 0 {
|
if len(issues) != 0 {
|
||||||
return reflect.Value{}, issues
|
return reflect.Value{}, issues
|
||||||
}
|
}
|
||||||
@@ -100,17 +108,17 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
|
|||||||
|
|
||||||
switch descriptor.kind {
|
switch descriptor.kind {
|
||||||
case descriptorStruct:
|
case descriptorStruct:
|
||||||
return decodeStruct(node, base, descriptor, path, schema)
|
return decodeStruct(node, base, descriptor, path, schema, context)
|
||||||
case descriptorMap:
|
case descriptorMap:
|
||||||
return decodeMap(node, base, descriptor, path, schema)
|
return decodeMap(node, base, descriptor, path, schema, context)
|
||||||
case descriptorSlice:
|
case descriptorSlice:
|
||||||
return decodeSlice(node, descriptor, path, schema)
|
return decodeSlice(node, descriptor, path, schema, context)
|
||||||
case descriptorArray:
|
case descriptorArray:
|
||||||
return decodeArray(node, descriptor, path, schema)
|
return decodeArray(node, descriptor, path, schema, context)
|
||||||
case descriptorDynamic:
|
case descriptorDynamic:
|
||||||
return decodeDynamic(node, base, path, schema)
|
return decodeDynamic(node, base, path, schema, context)
|
||||||
case descriptorString:
|
case descriptorString:
|
||||||
if node.Kind != document.String {
|
if node.Kind != document.String && !(pathMatches(context.versionPath, path) && isIntegerVersion(node)) {
|
||||||
return typeMismatch(path, node, "string")
|
return typeMismatch(path, node, "string")
|
||||||
}
|
}
|
||||||
result := reflect.New(descriptor.typeOf).Elem()
|
result := reflect.New(descriptor.typeOf).Elem()
|
||||||
@@ -171,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")
|
||||||
@@ -187,7 +195,7 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||||
if node.Kind != document.Mapping {
|
if node.Kind != document.Mapping {
|
||||||
return typeMismatch(path, node, "mapping")
|
return typeMismatch(path, node, "mapping")
|
||||||
}
|
}
|
||||||
@@ -203,7 +211,7 @@ func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescr
|
|||||||
issues = append(issues, decodeIssue{code: CodeSchemaUnsupportedType, path: childPath, message: "configuration field could not be addressed", location: entry.ValueLocation})
|
issues = append(issues, decodeIssue{code: CodeSchemaUnsupportedType, path: childPath, message: "configuration field could not be addressed", location: entry.ValueLocation})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
decoded, fieldIssues := decodeValue(entry.Value, target, field.descriptor, childPath, schema)
|
decoded, fieldIssues := decodeValue(entry.Value, target, field.descriptor, childPath, schema, context)
|
||||||
if len(fieldIssues) != 0 {
|
if len(fieldIssues) != 0 {
|
||||||
issues = append(issues, fieldIssues...)
|
issues = append(issues, fieldIssues...)
|
||||||
continue
|
continue
|
||||||
@@ -212,7 +220,7 @@ func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescr
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if descriptor.inlineMap != nil {
|
if descriptor.inlineMap != nil {
|
||||||
if issue := decodeInlineMapEntry(result, *descriptor.inlineMap, entry, childPath, schema); issue != nil {
|
if issue := decodeInlineMapEntry(result, *descriptor.inlineMap, entry, childPath, schema, context); issue != nil {
|
||||||
issues = append(issues, issue...)
|
issues = append(issues, issue...)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -222,7 +230,7 @@ func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescr
|
|||||||
return result, issues
|
return result, issues
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeInlineMapEntry(target reflect.Value, field fieldDescriptor, entry document.Entry, path Path, schema *schemaDescriptor) []decodeIssue {
|
func decodeInlineMapEntry(target reflect.Value, field fieldDescriptor, entry document.Entry, path Path, schema *schemaDescriptor, context decodeContext) []decodeIssue {
|
||||||
mapValue, ok := fieldForDecode(target, field.index, true)
|
mapValue, ok := fieldForDecode(target, field.index, true)
|
||||||
if !ok || mapValue.Kind() != reflect.Map {
|
if !ok || mapValue.Kind() != reflect.Map {
|
||||||
return []decodeIssue{{code: CodeSchemaInline, path: path, message: "inline map could not be addressed", location: entry.KeyLocation}}
|
return []decodeIssue{{code: CodeSchemaInline, path: path, message: "inline map could not be addressed", location: entry.KeyLocation}}
|
||||||
@@ -236,14 +244,14 @@ func decodeInlineMapEntry(target reflect.Value, field fieldDescriptor, entry doc
|
|||||||
if !base.IsValid() {
|
if !base.IsValid() {
|
||||||
base = reflect.Zero(mapValue.Type().Elem())
|
base = reflect.Zero(mapValue.Type().Elem())
|
||||||
}
|
}
|
||||||
decoded, issues := decodeValue(entry.Value, base, field.descriptor.element, path, schema)
|
decoded, issues := decodeValue(entry.Value, base, field.descriptor.element, path, schema, context)
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
mapValue.SetMapIndex(key, decoded)
|
mapValue.SetMapIndex(key, decoded)
|
||||||
}
|
}
|
||||||
return issues
|
return issues
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||||
if node.Kind != document.Mapping {
|
if node.Kind != document.Mapping {
|
||||||
return typeMismatch(path, node, "mapping")
|
return typeMismatch(path, node, "mapping")
|
||||||
}
|
}
|
||||||
@@ -259,7 +267,7 @@ func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescript
|
|||||||
if !entryBase.IsValid() {
|
if !entryBase.IsValid() {
|
||||||
entryBase = reflect.Zero(descriptor.typeOf.Elem())
|
entryBase = reflect.Zero(descriptor.typeOf.Elem())
|
||||||
}
|
}
|
||||||
decoded, entryIssues := decodeValue(entry.Value, entryBase, descriptor.element, path.Key(entry.Key), schema)
|
decoded, entryIssues := decodeValue(entry.Value, entryBase, descriptor.element, path.Key(entry.Key), schema, context)
|
||||||
if len(entryIssues) != 0 {
|
if len(entryIssues) != 0 {
|
||||||
issues = append(issues, entryIssues...)
|
issues = append(issues, entryIssues...)
|
||||||
continue
|
continue
|
||||||
@@ -269,14 +277,14 @@ func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescript
|
|||||||
return result, issues
|
return result, issues
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeSlice(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
func decodeSlice(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||||
if node.Kind != document.Sequence {
|
if node.Kind != document.Sequence {
|
||||||
return typeMismatch(path, node, "sequence")
|
return typeMismatch(path, node, "sequence")
|
||||||
}
|
}
|
||||||
result := reflect.MakeSlice(descriptor.typeOf, len(node.Sequence), len(node.Sequence))
|
result := reflect.MakeSlice(descriptor.typeOf, len(node.Sequence), len(node.Sequence))
|
||||||
var issues []decodeIssue
|
var issues []decodeIssue
|
||||||
for i, child := range node.Sequence {
|
for i, child := range node.Sequence {
|
||||||
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema)
|
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema, context)
|
||||||
if len(childIssues) != 0 {
|
if len(childIssues) != 0 {
|
||||||
issues = append(issues, childIssues...)
|
issues = append(issues, childIssues...)
|
||||||
continue
|
continue
|
||||||
@@ -286,7 +294,7 @@ func decodeSlice(node *document.Node, descriptor *typeDescriptor, path Path, sch
|
|||||||
return result, issues
|
return result, issues
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||||
if node.Kind != document.Sequence {
|
if node.Kind != document.Sequence {
|
||||||
return typeMismatch(path, node, "sequence")
|
return typeMismatch(path, node, "sequence")
|
||||||
}
|
}
|
||||||
@@ -296,7 +304,7 @@ func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, sch
|
|||||||
result := reflect.New(descriptor.typeOf).Elem()
|
result := reflect.New(descriptor.typeOf).Elem()
|
||||||
var issues []decodeIssue
|
var issues []decodeIssue
|
||||||
for i, child := range node.Sequence {
|
for i, child := range node.Sequence {
|
||||||
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema)
|
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema, context)
|
||||||
if len(childIssues) != 0 {
|
if len(childIssues) != 0 {
|
||||||
issues = append(issues, childIssues...)
|
issues = append(issues, childIssues...)
|
||||||
continue
|
continue
|
||||||
@@ -306,13 +314,13 @@ func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, sch
|
|||||||
return result, issues
|
return result, issues
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||||
if node.Kind == document.Mapping && base.IsValid() && !base.IsNil() {
|
if node.Kind == document.Mapping && base.IsValid() && !base.IsNil() {
|
||||||
concrete := base.Elem()
|
concrete := base.Elem()
|
||||||
operation := valueOperation{schema: schema}
|
operation := valueOperation{schema: schema}
|
||||||
descriptor, issue := operation.dynamicDescriptor(concrete.Type(), path)
|
descriptor, issue := operation.dynamicDescriptor(concrete.Type(), path)
|
||||||
if issue == nil && acceptsMapping(descriptor) {
|
if issue == nil && acceptsMapping(descriptor) {
|
||||||
decoded, issues := decodeValue(node, concrete, descriptor, path, schema)
|
decoded, issues := decodeValue(node, concrete, descriptor, path, schema, context)
|
||||||
if len(issues) != 0 {
|
if len(issues) != 0 {
|
||||||
return reflect.Value{}, issues
|
return reflect.Value{}, issues
|
||||||
}
|
}
|
||||||
@@ -347,7 +355,7 @@ func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *s
|
|||||||
sequence := make([]any, len(node.Sequence))
|
sequence := make([]any, len(node.Sequence))
|
||||||
var issues []decodeIssue
|
var issues []decodeIssue
|
||||||
for i, child := range node.Sequence {
|
for i, child := range node.Sequence {
|
||||||
decoded, childIssues := decodeDynamic(child, reflect.Zero(reflect.TypeFor[any]()), path.Index(i), schema)
|
decoded, childIssues := decodeDynamic(child, reflect.Zero(reflect.TypeFor[any]()), path.Index(i), schema, context)
|
||||||
if len(childIssues) != 0 {
|
if len(childIssues) != 0 {
|
||||||
issues = append(issues, childIssues...)
|
issues = append(issues, childIssues...)
|
||||||
continue
|
continue
|
||||||
@@ -362,7 +370,7 @@ func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *s
|
|||||||
mapping := make(map[string]any, len(node.Mapping))
|
mapping := make(map[string]any, len(node.Mapping))
|
||||||
var issues []decodeIssue
|
var issues []decodeIssue
|
||||||
for _, entry := range node.Mapping {
|
for _, entry := range node.Mapping {
|
||||||
decoded, childIssues := decodeDynamic(entry.Value, reflect.Zero(reflect.TypeFor[any]()), path.Key(entry.Key), schema)
|
decoded, childIssues := decodeDynamic(entry.Value, reflect.Zero(reflect.TypeFor[any]()), path.Key(entry.Key), schema, context)
|
||||||
if len(childIssues) != 0 {
|
if len(childIssues) != 0 {
|
||||||
issues = append(issues, childIssues...)
|
issues = append(issues, childIssues...)
|
||||||
continue
|
continue
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
248
internal/engine/diagnostic.go
Normal file
248
internal/engine/diagnostic.go
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadError reports safe structured diagnostics for a failed configuration load.
|
||||||
|
// It intentionally does not expose or unwrap parser and application errors.
|
||||||
|
type LoadError struct {
|
||||||
|
diagnostics []Diagnostic
|
||||||
|
sources []SourceInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error implements error without rendering diagnostic messages or input values.
|
||||||
|
func (e *LoadError) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return "configkit: configuration load failed"
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for _, diagnostic := range e.diagnostics {
|
||||||
|
if diagnostic.Severity == SeverityError {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count == 1 {
|
||||||
|
return "configkit: configuration load failed with 1 error"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("configkit: configuration load failed with %d errors", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagnostics returns a deep copy of the error's canonical diagnostics.
|
||||||
|
func (e *LoadError) Diagnostics() []Diagnostic {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cloneDiagnostics(e.diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sources returns a deep copy of sources discovered before loading failed.
|
||||||
|
func (e *LoadError) Sources() []SourceInfo {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cloneSourceInfos(e.sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteDiagnostics writes diagnostics in canonical order. It escapes terminal
|
||||||
|
// control characters and propagates writer errors, including short writes.
|
||||||
|
func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
|
||||||
|
canonical := canonicalDiagnostics(diagnostics)
|
||||||
|
for _, diagnostic := range canonical {
|
||||||
|
if err := writeDiagnostic(output, diagnostic); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type diagnosticAccumulator struct {
|
||||||
|
diagnostics []Diagnostic
|
||||||
|
sources []SourceInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *diagnosticAccumulator) add(diagnostics ...Diagnostic) {
|
||||||
|
for _, diagnostic := range diagnostics {
|
||||||
|
a.diagnostics = append(a.diagnostics, cloneDiagnostic(diagnostic))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *diagnosticAccumulator) loadError() *LoadError {
|
||||||
|
return &LoadError{
|
||||||
|
diagnostics: canonicalDiagnostics(a.diagnostics),
|
||||||
|
sources: cloneSourceInfos(a.sources),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalDiagnostics(diagnostics []Diagnostic) []Diagnostic {
|
||||||
|
canonical := cloneDiagnostics(diagnostics)
|
||||||
|
for i := range canonical {
|
||||||
|
slices.SortFunc(canonical[i].Related, compareLocation)
|
||||||
|
}
|
||||||
|
slices.SortFunc(canonical, compareDiagnostic)
|
||||||
|
return canonical
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareDiagnostic(left, right Diagnostic) int {
|
||||||
|
if order := compareOptionalLocation(left.Primary, right.Primary); order != 0 {
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
if order := cmp.Compare(left.Path.String(), right.Path.String()); order != 0 {
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
if order := cmp.Compare(left.Code, right.Code); order != 0 {
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
if order := cmp.Compare(left.Severity, right.Severity); order != 0 {
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
if order := cmp.Compare(left.Message, right.Message); order != 0 {
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
return compareLocations(left.Related, right.Related)
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareOptionalLocation(left, right *Location) int {
|
||||||
|
if left == nil && right == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if left == nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if right == nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return compareLocation(*left, *right)
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareLocations(left, right []Location) int {
|
||||||
|
for i := 0; i < min(len(left), len(right)); i++ {
|
||||||
|
if order := compareLocation(left[i], right[i]); order != 0 {
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cmp.Compare(len(left), len(right))
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareLocation(left, right Location) int {
|
||||||
|
if order := compareSourceID(left.SourceID, right.SourceID); order != 0 {
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
if order := cmp.Compare(left.Line, right.Line); order != 0 {
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
return cmp.Compare(left.Column, right.Column)
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareSourceID(left, right string) int {
|
||||||
|
leftOrder, leftOK := sourceSequence(left)
|
||||||
|
rightOrder, rightOK := sourceSequence(right)
|
||||||
|
if leftOK && rightOK {
|
||||||
|
return cmp.Compare(leftOrder, rightOrder)
|
||||||
|
}
|
||||||
|
return cmp.Compare(left, right)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceSequence(sourceID string) (int, bool) {
|
||||||
|
const prefix = "source-"
|
||||||
|
if !strings.HasPrefix(sourceID, prefix) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
value, err := strconv.Atoi(strings.TrimPrefix(sourceID, prefix))
|
||||||
|
if err != nil || value < 1 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeDiagnostic(output io.Writer, diagnostic Diagnostic) error {
|
||||||
|
severity := diagnostic.Severity
|
||||||
|
if severity == "" {
|
||||||
|
severity = SeverityError
|
||||||
|
}
|
||||||
|
path := diagnostic.Path.String()
|
||||||
|
if path == "" {
|
||||||
|
path = "<root>"
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(escapeControls(string(severity)))
|
||||||
|
if diagnostic.Code != "" {
|
||||||
|
b.WriteString(" [")
|
||||||
|
b.WriteString(escapeControls(string(diagnostic.Code)))
|
||||||
|
b.WriteByte(']')
|
||||||
|
}
|
||||||
|
b.WriteByte(' ')
|
||||||
|
b.WriteString(escapeControls(path))
|
||||||
|
if diagnostic.Primary != nil {
|
||||||
|
b.WriteString(" (")
|
||||||
|
writeLocationText(&b, *diagnostic.Primary)
|
||||||
|
b.WriteByte(')')
|
||||||
|
}
|
||||||
|
if diagnostic.Message != "" {
|
||||||
|
b.WriteString(": ")
|
||||||
|
b.WriteString(escapeControls(diagnostic.Message))
|
||||||
|
}
|
||||||
|
b.WriteByte('\n')
|
||||||
|
for _, related := range diagnostic.Related {
|
||||||
|
b.WriteString(" related: ")
|
||||||
|
writeLocationText(&b, related)
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
return writeString(output, b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeLocationText(output *strings.Builder, location Location) {
|
||||||
|
label := location.SourceID
|
||||||
|
if label == "" {
|
||||||
|
label = "<synthetic>"
|
||||||
|
}
|
||||||
|
output.WriteString(escapeControls(label))
|
||||||
|
if location.Line > 0 {
|
||||||
|
output.WriteByte(':')
|
||||||
|
output.WriteString(strconv.Itoa(location.Line))
|
||||||
|
if location.Column > 0 {
|
||||||
|
output.WriteByte(':')
|
||||||
|
output.WriteString(strconv.Itoa(location.Column))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeString(output io.Writer, value string) error {
|
||||||
|
written, err := io.WriteString(output, value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if written != len(value) {
|
||||||
|
return io.ErrShortWrite
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeControls(value string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range value {
|
||||||
|
if unicode.IsControl(r) {
|
||||||
|
quoted := strconv.QuoteRune(r)
|
||||||
|
b.WriteString(quoted[1 : len(quoted)-1])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasErrors(diagnostics []Diagnostic) bool {
|
||||||
|
for _, diagnostic := range diagnostics {
|
||||||
|
if diagnostic.Severity == SeverityError {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
229
internal/engine/directives.go
Normal file
229
internal/engine/directives.go
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
const directiveKey = "configkit"
|
||||||
|
|
||||||
|
type importDeclaration struct {
|
||||||
|
path string
|
||||||
|
glob string
|
||||||
|
optional bool
|
||||||
|
location Location
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitImportDirectives(root *document.Node) (*document.Node, []importDeclaration, []Diagnostic) {
|
||||||
|
body := document.Clone(root)
|
||||||
|
var declarations []importDeclaration
|
||||||
|
directiveIndex := -1
|
||||||
|
for index, entry := range body.Mapping {
|
||||||
|
if entry.Key != directiveKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
directiveIndex = index
|
||||||
|
parsed, diagnostics := parseImportDirective(entry.Value, Keys(directiveKey))
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, nil, diagnostics
|
||||||
|
}
|
||||||
|
declarations = parsed
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if directiveIndex >= 0 {
|
||||||
|
body.Mapping = append(body.Mapping[:directiveIndex], body.Mapping[directiveIndex+1:]...)
|
||||||
|
}
|
||||||
|
if diagnostics := nestedDirectiveDiagnostics(body, Path{}); len(diagnostics) != 0 {
|
||||||
|
return nil, nil, diagnostics
|
||||||
|
}
|
||||||
|
return body, declarations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseImportDirective(node *document.Node, directivePath Path) ([]importDeclaration, []Diagnostic) {
|
||||||
|
if node.Kind != document.Mapping {
|
||||||
|
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, directivePath, "configuration directive must be a mapping", node)}
|
||||||
|
}
|
||||||
|
var declarations []importDeclaration
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
fieldPath := directivePath.Key(entry.Key)
|
||||||
|
if entry.Key != "imports" {
|
||||||
|
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "configuration directive field is not recognized", entry.Value)}
|
||||||
|
}
|
||||||
|
if entry.Value.Kind != document.Sequence {
|
||||||
|
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "imports must be a sequence", entry.Value)}
|
||||||
|
}
|
||||||
|
for index, item := range entry.Value.Sequence {
|
||||||
|
declaration, diagnostics := parseImportDeclaration(item, fieldPath.Index(index))
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, diagnostics
|
||||||
|
}
|
||||||
|
declarations = append(declarations, declaration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return declarations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseImportDeclaration(node *document.Node, declarationPath Path) (importDeclaration, []Diagnostic) {
|
||||||
|
if node.Kind != document.Mapping {
|
||||||
|
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration must be a mapping", node)}
|
||||||
|
}
|
||||||
|
declaration := importDeclaration{location: publicLocation(node.Location)}
|
||||||
|
seenTarget := false
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
fieldPath := declarationPath.Key(entry.Key)
|
||||||
|
switch entry.Key {
|
||||||
|
case "path", "glob":
|
||||||
|
if seenTarget || entry.Value.Kind != document.String || entry.Value.Text == "" || strings.TrimSpace(entry.Value.Text) != entry.Value.Text {
|
||||||
|
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration requires exactly one nonempty path or glob string", entry.Value)}
|
||||||
|
}
|
||||||
|
seenTarget = true
|
||||||
|
declaration.location = publicLocation(entry.Value.Location)
|
||||||
|
if entry.Key == "path" {
|
||||||
|
declaration.path = entry.Value.Text
|
||||||
|
} else {
|
||||||
|
declaration.glob = entry.Value.Text
|
||||||
|
}
|
||||||
|
case "optional":
|
||||||
|
if entry.Value.Kind != document.Boolean {
|
||||||
|
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "optional must be a boolean", entry.Value)}
|
||||||
|
}
|
||||||
|
declaration.optional = entry.Value.Text == "true"
|
||||||
|
default:
|
||||||
|
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not recognized", entry.Value)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !seenTarget {
|
||||||
|
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration requires exactly one path or glob", node)}
|
||||||
|
}
|
||||||
|
return declaration, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nestedDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic {
|
||||||
|
if node.Kind == document.Mapping {
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
childPath := current.Key(entry.Key)
|
||||||
|
if entry.Key == directiveKey {
|
||||||
|
location := publicLocation(entry.KeyLocation)
|
||||||
|
return []Diagnostic{{Code: CodeImportInvalid, Severity: SeverityError, Path: childPath, Message: "configuration directives are permitted only at the document root", Primary: &location}}
|
||||||
|
}
|
||||||
|
if diagnostics := nestedDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 {
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if node.Kind == document.Sequence {
|
||||||
|
for index, child := range node.Sequence {
|
||||||
|
if diagnostics := nestedDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 {
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fragmentVersionClaim(root *document.Node, rule *VersionRule) *document.Node {
|
||||||
|
if rule == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
current := root
|
||||||
|
if len(rule.Path.segments) == 0 {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
for _, segment := range rule.Path.segments {
|
||||||
|
if current.Kind != document.Mapping {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
if segment.kind == pathSegmentIndex {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
key := segment.key
|
||||||
|
index := documentEntryIndex(current.Mapping, key)
|
||||||
|
if index < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
current = current.Mapping[index].Value
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
func validImportPath(value string) bool {
|
||||||
|
if value == "" || strings.TrimSpace(value) != value || path.IsAbs(value) || strings.HasPrefix(value, "//") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !hasImportScheme(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasImportScheme(value string) bool {
|
||||||
|
colon := strings.IndexByte(value, ':')
|
||||||
|
slash := strings.IndexByte(value, '/')
|
||||||
|
if colon <= 0 || (slash >= 0 && slash < colon) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for index := 0; index < colon; index++ {
|
||||||
|
character := value[index]
|
||||||
|
if index == 0 {
|
||||||
|
if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') &&
|
||||||
|
(character < '0' || character > '9') && character != '+' && character != '-' && character != '.' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func nodeImportDiagnostic(code Code, path Path, message string, node *document.Node) Diagnostic {
|
||||||
|
location := publicLocation(node.Location)
|
||||||
|
return Diagnostic{Code: code, Severity: SeverityError, Path: path, Message: message, Primary: &location}
|
||||||
|
}
|
||||||
|
|
||||||
|
func overlayDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic {
|
||||||
|
if node.Kind == document.Mapping {
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
childPath := current.Key(entry.Key)
|
||||||
|
if entry.Key == directiveKey {
|
||||||
|
location := publicLocation(entry.KeyLocation)
|
||||||
|
return []Diagnostic{{
|
||||||
|
Code: CodeOverlayDirective, Severity: SeverityError, Path: childPath,
|
||||||
|
Message: "overlays cannot contain configuration directives", Primary: &location,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if diagnostics := overlayDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 {
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if node.Kind == document.Sequence {
|
||||||
|
for index, child := range node.Sequence {
|
||||||
|
if diagnostics := overlayDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 {
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func disabledDirectiveDiagnostics(node *document.Node, path Path) []Diagnostic {
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
if node.Kind == document.Mapping {
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
childPath := path.Key(entry.Key)
|
||||||
|
if entry.Key == directiveKey {
|
||||||
|
location := Location{SourceID: entry.KeyLocation.SourceID, Line: entry.KeyLocation.Line, Column: entry.KeyLocation.Column}
|
||||||
|
diagnostics = append(diagnostics, Diagnostic{
|
||||||
|
Code: CodeImportDisabled, Severity: SeverityError, Path: childPath,
|
||||||
|
Message: "configuration directives require imports to be enabled", Primary: &location,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
diagnostics = append(diagnostics, disabledDirectiveDiagnostics(entry.Value, childPath)...)
|
||||||
|
}
|
||||||
|
} else if node.Kind == document.Sequence {
|
||||||
|
for index, child := range node.Sequence {
|
||||||
|
diagnostics = append(diagnostics, disabledDirectiveDiagnostics(child, path.Index(index))...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
110
internal/engine/document_compare.go
Normal file
110
internal/engine/document_compare.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
type documentDeltaKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
documentAdded documentDeltaKind = iota + 1
|
||||||
|
documentRemoved
|
||||||
|
documentReplaced
|
||||||
|
)
|
||||||
|
|
||||||
|
type documentDelta struct {
|
||||||
|
path Path
|
||||||
|
kind documentDeltaKind
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareConfigurationValues ignores locations and sensitivity metadata. Mapping
|
||||||
|
// order is immaterial; sequences are atomic for change reporting.
|
||||||
|
func compareConfigurationValues(before, after *document.Node) []documentDelta {
|
||||||
|
var deltas []documentDelta
|
||||||
|
compareConfigurationValue(before, after, Path{}, &deltas)
|
||||||
|
slices.SortFunc(deltas, func(left, right documentDelta) int {
|
||||||
|
if order := cmp.Compare(left.path.String(), right.path.String()); order != 0 {
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
return int(left.kind) - int(right.kind)
|
||||||
|
})
|
||||||
|
return deltas
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareConfigurationValue(before, after *document.Node, path Path, deltas *[]documentDelta) {
|
||||||
|
if before == nil && after == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if before == nil {
|
||||||
|
*deltas = append(*deltas, documentDelta{path: path.clone(), kind: documentAdded})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if after == nil {
|
||||||
|
*deltas = append(*deltas, documentDelta{path: path.clone(), kind: documentRemoved})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if before.Kind != after.Kind {
|
||||||
|
*deltas = append(*deltas, documentDelta{path: path.clone(), kind: documentReplaced})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch before.Kind {
|
||||||
|
case document.Mapping:
|
||||||
|
beforeEntries := make(map[string]*document.Node, len(before.Mapping))
|
||||||
|
afterEntries := make(map[string]*document.Node, len(after.Mapping))
|
||||||
|
keys := make(map[string]bool, len(before.Mapping)+len(after.Mapping))
|
||||||
|
for _, entry := range before.Mapping {
|
||||||
|
beforeEntries[entry.Key] = entry.Value
|
||||||
|
keys[entry.Key] = true
|
||||||
|
}
|
||||||
|
for _, entry := range after.Mapping {
|
||||||
|
afterEntries[entry.Key] = entry.Value
|
||||||
|
keys[entry.Key] = true
|
||||||
|
}
|
||||||
|
ordered := make([]string, 0, len(keys))
|
||||||
|
for key := range keys {
|
||||||
|
ordered = append(ordered, key)
|
||||||
|
}
|
||||||
|
slices.Sort(ordered)
|
||||||
|
for _, key := range ordered {
|
||||||
|
compareConfigurationValue(beforeEntries[key], afterEntries[key], path.Key(key), deltas)
|
||||||
|
}
|
||||||
|
case document.Sequence:
|
||||||
|
if !configurationValuesEqual(before, after) {
|
||||||
|
*deltas = append(*deltas, documentDelta{path: path.clone(), kind: documentReplaced})
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if before.Text != after.Text {
|
||||||
|
*deltas = append(*deltas, documentDelta{path: path.clone(), kind: documentReplaced})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// configurationValuesEqual compares only configuration values, not provenance or
|
||||||
|
// sensitivity. Callers auditing validation must check sensitivity separately.
|
||||||
|
func configurationValuesEqual(left, right *document.Node) bool {
|
||||||
|
if left == nil || right == nil {
|
||||||
|
return left == right
|
||||||
|
}
|
||||||
|
if left.Kind != right.Kind || left.Text != right.Text || len(left.Mapping) != len(right.Mapping) || len(left.Sequence) != len(right.Sequence) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if left.Kind == document.Mapping {
|
||||||
|
leftMap := make(map[string]*document.Node, len(left.Mapping))
|
||||||
|
for _, entry := range left.Mapping {
|
||||||
|
leftMap[entry.Key] = entry.Value
|
||||||
|
}
|
||||||
|
for _, entry := range right.Mapping {
|
||||||
|
if !configurationValuesEqual(leftMap[entry.Key], entry.Value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range left.Sequence {
|
||||||
|
if !configurationValuesEqual(left.Sequence[i], right.Sequence[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
86
internal/engine/document_walk.go
Normal file
86
internal/engine/document_walk.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
|
||||||
|
// walkDocument visits value nodes in preorder, preserving stored mapping order
|
||||||
|
// and sequence index order. Mapping keys are not separate nodes in this walk.
|
||||||
|
// The callback borrows paths and nodes; clone any mutable state it retains.
|
||||||
|
// Specialized origin, pruning, and budget traversals retain their own semantics.
|
||||||
|
func walkDocument(node *document.Node, path Path, visit func(Path, *document.Node)) {
|
||||||
|
if node == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
visit(path, node)
|
||||||
|
switch node.Kind {
|
||||||
|
case document.Mapping:
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
walkDocument(entry.Value, path.Key(entry.Key), visit)
|
||||||
|
}
|
||||||
|
case document.Sequence:
|
||||||
|
for index, child := range node.Sequence {
|
||||||
|
walkDocument(child, path.Index(index), visit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexDocument borrows nodes from its caller-owned tree.
|
||||||
|
func indexDocument(node *document.Node, path Path, index map[string]*document.Node) {
|
||||||
|
walkDocument(node, path, func(path Path, node *document.Node) {
|
||||||
|
index[path.String()] = node
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentAtPath(root *document.Node, path Path) (*document.Node, bool) {
|
||||||
|
if root == nil || !path.valid() {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
current := root
|
||||||
|
for _, segment := range path.segments {
|
||||||
|
switch current.Kind {
|
||||||
|
case document.Mapping:
|
||||||
|
if segment.kind == pathSegmentIndex {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, entry := range current.Mapping {
|
||||||
|
if entry.Key == segment.key {
|
||||||
|
current = entry.Value
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
case document.Sequence:
|
||||||
|
index, ok := schemaIndex(segment)
|
||||||
|
if !ok || index >= len(current.Sequence) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
current = current.Sequence[index]
|
||||||
|
default:
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// countInputNodes includes mapping-key nodes, unlike walkDocument's value-only
|
||||||
|
// traversal. Aggregate source budgets charge every input occurrence.
|
||||||
|
func countInputNodes(node *document.Node) int {
|
||||||
|
if node == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
count := 1
|
||||||
|
if node.Kind == document.Mapping {
|
||||||
|
count += len(node.Mapping)
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
count += countInputNodes(entry.Value)
|
||||||
|
}
|
||||||
|
} else if node.Kind == document.Sequence {
|
||||||
|
for _, child := range node.Sequence {
|
||||||
|
count += countInputNodes(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
122
internal/engine/explain.go
Normal file
122
internal/engine/explain.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
|
||||||
|
// ValueSnapshot is a detached, deterministic YAML representation of one value.
|
||||||
|
// Present distinguishes absence or deletion from an explicit null value.
|
||||||
|
type ValueSnapshot struct {
|
||||||
|
Present bool
|
||||||
|
Redacted bool
|
||||||
|
YAML string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValueChange describes one explicit definition or audited domain operation.
|
||||||
|
type ValueChange struct {
|
||||||
|
Before ValueSnapshot
|
||||||
|
After ValueSnapshot
|
||||||
|
Origin Origin
|
||||||
|
Reasons []string
|
||||||
|
Dependencies []Path
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretReferenceInfo describes a successfully resolved effective reference.
|
||||||
|
// Name is the environment-variable name, never its value.
|
||||||
|
type SecretReferenceInfo struct {
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explanation is a detached safe account of one effective or historical path.
|
||||||
|
type Explanation struct {
|
||||||
|
Path Path
|
||||||
|
Effective ValueSnapshot
|
||||||
|
Origin Origin
|
||||||
|
History []ValueChange
|
||||||
|
SecretReference *SecretReferenceInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explain returns safe effective and chronological values for a known final or
|
||||||
|
// historical path. Unknown and malformed paths return false.
|
||||||
|
func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
||||||
|
if r == nil || r.final == nil || r.baseline == nil || !r.metadata.hasHistoricalPath(path) {
|
||||||
|
return Explanation{}, false
|
||||||
|
}
|
||||||
|
explanation := Explanation{Path: path.clone(), Effective: valueSnapshot(r.final, path, r.secrets)}
|
||||||
|
if info, ok := r.metadata.Lookup(path); ok {
|
||||||
|
explanation.Origin = cloneOrigin(info.Origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
working := document.Clone(r.baseline)
|
||||||
|
if _, present := documentAtPath(working, path); present {
|
||||||
|
explanation.History = append(explanation.History, ValueChange{
|
||||||
|
After: valueSnapshot(working, path, r.secrets), Origin: Origin{Layer: LayerDefault},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, application := range r.applications {
|
||||||
|
operationPath, relevant := applicationOperationPath(application, path)
|
||||||
|
var before ValueSnapshot
|
||||||
|
if relevant {
|
||||||
|
before = valueSnapshot(working, path, r.secrets)
|
||||||
|
}
|
||||||
|
applyHistoryApplication(&working, application)
|
||||||
|
if !relevant {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
after := valueSnapshot(working, path, r.secrets)
|
||||||
|
if !before.Present && !after.Present {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
explanation.History = append(explanation.History, ValueChange{
|
||||||
|
Before: before,
|
||||||
|
After: after,
|
||||||
|
Origin: historyApplicationOrigin(application, operationPath),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intermediate input replacements do not permanently suppress defaults:
|
||||||
|
// decoding applies them to the final input tree. Show that reconciliation
|
||||||
|
// explicitly, including canonical scalar conversions, before domain changes.
|
||||||
|
replayed, _ := documentAtPath(working, path)
|
||||||
|
decoded, _ := documentAtPath(r.input, path)
|
||||||
|
if !configurationValuesEqual(replayed, decoded) {
|
||||||
|
origin := cloneOrigin(r.inputOrigins[path.String()])
|
||||||
|
origin.Operation = ChangeDecoding
|
||||||
|
explanation.History = append(explanation.History, ValueChange{
|
||||||
|
Before: valueSnapshot(working, path, r.secrets),
|
||||||
|
After: valueSnapshot(r.input, path, r.secrets),
|
||||||
|
Origin: origin,
|
||||||
|
Reasons: []string{"applied retained defaults and canonical typed decoding"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
working = document.Clone(r.input)
|
||||||
|
for _, snapshot := range coalescedResolutionSnapshots(r.resolution) {
|
||||||
|
relevant := pathsIntersect(snapshot.change.Path, path)
|
||||||
|
var before ValueSnapshot
|
||||||
|
if relevant {
|
||||||
|
before = valueSnapshot(working, path, r.secrets)
|
||||||
|
}
|
||||||
|
replaceHistoryPath(&working, snapshot.change.Path, snapshot.after)
|
||||||
|
if !relevant {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
after := valueSnapshot(working, path, r.secrets)
|
||||||
|
if !before.Present && !after.Present {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
explanation.History = append(explanation.History, ValueChange{
|
||||||
|
Before: before,
|
||||||
|
After: after,
|
||||||
|
Origin: cloneOrigin(snapshot.change.Origin),
|
||||||
|
Reasons: append([]string(nil), snapshot.change.Reasons...),
|
||||||
|
Dependencies: clonePaths(snapshot.change.Dependencies),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !explanation.Effective.Present && len(explanation.History) == 0 {
|
||||||
|
return Explanation{}, false
|
||||||
|
}
|
||||||
|
if name, ok := r.secretReferences[path.String()]; ok && !pathIsSecret(r.secrets, path) {
|
||||||
|
explanation.SecretReference = &SecretReferenceInfo{Name: name}
|
||||||
|
}
|
||||||
|
return explanation, true
|
||||||
|
}
|
||||||
123
internal/engine/explain_render.go
Normal file
123
internal/engine/explain_render.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errExplanationUnavailable = errors.New("configkit: configuration explanation is unavailable")
|
||||||
|
errExplanationUnknownPath = errors.New("configkit: configuration explanation path is unknown")
|
||||||
|
)
|
||||||
|
|
||||||
|
// WriteExplanation writes a deterministic human-readable rendering of Explain.
|
||||||
|
// Unknown paths produce no output. Writer failures may leave partial output.
|
||||||
|
func (r *Inspection) WriteExplanation(output io.Writer, path Path) error {
|
||||||
|
if r == nil || output == nil {
|
||||||
|
return errExplanationUnavailable
|
||||||
|
}
|
||||||
|
explanation, ok := r.Explain(path)
|
||||||
|
if !ok {
|
||||||
|
return errExplanationUnknownPath
|
||||||
|
}
|
||||||
|
var rendered strings.Builder
|
||||||
|
rendered.WriteString("path: ")
|
||||||
|
rendered.WriteString(strconv.Quote(explanation.Path.String()))
|
||||||
|
rendered.WriteByte('\n')
|
||||||
|
writeExplanationOrigin(&rendered, "effective source", explanation.Origin, r.sources)
|
||||||
|
writeExplanationSnapshot(&rendered, "effective (canonical typed)", explanation.Effective, 0)
|
||||||
|
if explanation.SecretReference != nil {
|
||||||
|
rendered.WriteString("secret reference: ")
|
||||||
|
rendered.WriteString(strconv.Quote(explanation.SecretReference.Name))
|
||||||
|
rendered.WriteString(" (resolved from process environment)\n")
|
||||||
|
}
|
||||||
|
rendered.WriteString("history:\n")
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
rendered.WriteString(" - representation: ")
|
||||||
|
if change.Origin.Operation == ChangeDecoding {
|
||||||
|
rendered.WriteString("input to canonical typed\n")
|
||||||
|
} else if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerOverlay || change.Origin.Layer == LayerCLI {
|
||||||
|
rendered.WriteString("policy-validated input\n")
|
||||||
|
} else {
|
||||||
|
rendered.WriteString("canonical typed\n")
|
||||||
|
}
|
||||||
|
writeExplanationOrigin(&rendered, " source", change.Origin, r.sources)
|
||||||
|
writeExplanationSnapshot(&rendered, " before", change.Before, 4)
|
||||||
|
writeExplanationSnapshot(&rendered, " after", change.After, 4)
|
||||||
|
for _, reason := range change.Reasons {
|
||||||
|
rendered.WriteString(" reason: ")
|
||||||
|
rendered.WriteString(strconv.Quote(reason))
|
||||||
|
rendered.WriteByte('\n')
|
||||||
|
}
|
||||||
|
for _, dependency := range change.Dependencies {
|
||||||
|
rendered.WriteString(" dependency: ")
|
||||||
|
rendered.WriteString(strconv.Quote(dependency.String()))
|
||||||
|
rendered.WriteByte('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data := []byte(rendered.String())
|
||||||
|
written, err := output.Write(data)
|
||||||
|
if err == nil && written != len(data) {
|
||||||
|
return io.ErrShortWrite
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeExplanationOrigin(output *strings.Builder, label string, origin Origin, sources []SourceInfo) {
|
||||||
|
output.WriteString(label)
|
||||||
|
output.WriteString(": layer=")
|
||||||
|
output.WriteString(strconv.Quote(string(origin.Layer)))
|
||||||
|
if origin.Operation != "" {
|
||||||
|
output.WriteString(" operation=")
|
||||||
|
output.WriteString(strconv.Quote(string(origin.Operation)))
|
||||||
|
}
|
||||||
|
output.WriteByte('\n')
|
||||||
|
for _, location := range origin.Locations {
|
||||||
|
output.WriteString(" location: source=")
|
||||||
|
output.WriteString(strconv.Quote(location.SourceID))
|
||||||
|
if label := sourceLabel(sources, location.SourceID); label != "" {
|
||||||
|
output.WriteString(" label=")
|
||||||
|
output.WriteString(strconv.Quote(label))
|
||||||
|
}
|
||||||
|
output.WriteString(" line=")
|
||||||
|
output.WriteString(strconv.Itoa(location.Line))
|
||||||
|
output.WriteString(" column=")
|
||||||
|
output.WriteString(strconv.Itoa(location.Column))
|
||||||
|
output.WriteByte('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceLabel(sources []SourceInfo, sourceID string) string {
|
||||||
|
for _, source := range sources {
|
||||||
|
if source.ID == sourceID {
|
||||||
|
return source.Label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeExplanationSnapshot(output *strings.Builder, label string, snapshot ValueSnapshot, indent int) {
|
||||||
|
if !snapshot.Present {
|
||||||
|
output.WriteString(label)
|
||||||
|
output.WriteString(": absent\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
output.WriteString(label)
|
||||||
|
if snapshot.Redacted {
|
||||||
|
output.WriteString(" (redacted)")
|
||||||
|
}
|
||||||
|
output.WriteString(":\n")
|
||||||
|
prefix := strings.Repeat(" ", indent+2)
|
||||||
|
for _, line := range strings.SplitAfter(snapshot.YAML, "\n") {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
output.WriteString(prefix)
|
||||||
|
output.WriteString(line)
|
||||||
|
if !strings.HasSuffix(line, "\n") {
|
||||||
|
output.WriteByte('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -21,13 +21,13 @@ type explanationConcurrency struct {
|
|||||||
} `yaml:"transcription"`
|
} `yaml:"transcription"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExplainShowsDefaultBaseAndEffectiveProfileValue(t *testing.T) {
|
func TestExplainShowsDefaultBaseAndEffectiveOverlayValue(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
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("overlays", "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, Overlay: "testing",
|
||||||
Definition: Definition[explanationConcurrency]{Defaults: func() explanationConcurrency {
|
Definition: Definition[explanationConcurrency]{Defaults: func() explanationConcurrency {
|
||||||
value := explanationConcurrency{}
|
value := explanationConcurrency{}
|
||||||
value.Transcription.Concurrency = 4
|
value.Transcription.Concurrency = 4
|
||||||
@@ -44,11 +44,17 @@ func TestExplainShowsDefaultBaseAndEffectiveProfileValue(t *testing.T) {
|
|||||||
if got := explanationValues(explanation); !reflect.DeepEqual(got, []string{"4\n", "8\n", "1\n"}) {
|
if got := explanationValues(explanation); !reflect.DeepEqual(got, []string{"4\n", "8\n", "1\n"}) {
|
||||||
t.Fatalf("history values = %#v", got)
|
t.Fatalf("history values = %#v", got)
|
||||||
}
|
}
|
||||||
if explanation.Effective.YAML != "1\n" || explanation.Origin.Layer != LayerProfile ||
|
if explanation.Effective.YAML != "1\n" || explanation.Origin.Layer != LayerOverlay ||
|
||||||
len(explanation.Origin.Locations) != 1 || explanation.Origin.Locations[0].SourceID != "source-3" ||
|
len(explanation.Origin.Locations) != 1 || explanation.Origin.Locations[0].SourceID != "source-3" ||
|
||||||
explanation.Origin.Locations[0].Line != 2 {
|
explanation.Origin.Locations[0].Line != 2 {
|
||||||
t.Fatalf("effective explanation = %#v", explanation)
|
t.Fatalf("effective explanation = %#v", explanation)
|
||||||
}
|
}
|
||||||
|
var rendered bytes.Buffer
|
||||||
|
if err := result.WriteExplanation(&rendered, Keys("transcription", "concurrency")); err != nil ||
|
||||||
|
!strings.Contains(rendered.String(), `layer="overlay"`) ||
|
||||||
|
!strings.Contains(rendered.String(), `label="overlays/testing.yml"`) {
|
||||||
|
t.Fatalf("rendered explanation = %v\n%s", err, rendered.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type explanationConfig struct {
|
type explanationConfig struct {
|
||||||
@@ -70,27 +76,27 @@ func TestExplainReplaysCLIResolutionContainersAndSafeHistoricalValues(t *testing
|
|||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "config.yml", `concurrency: 8
|
mainPath := writeImportFile(t, directory, "config.yml", `concurrency: 8
|
||||||
equal: same
|
equal: same
|
||||||
limit: wrong-before-profile
|
limit: wrong-before-overlay
|
||||||
items: [base, removed]
|
items: [base, removed]
|
||||||
labels: {base: retained}
|
labels: {base: retained}
|
||||||
dynamic: {retired: HISTORICAL_EXPLAIN_CANARY, public: visible}
|
dynamic: {retired: HISTORICAL_EXPLAIN_CANARY, public: visible}
|
||||||
credential: APP_SECRET
|
credential: APP_SECRET
|
||||||
classified: APP_SECRET
|
classified: APP_SECRET
|
||||||
`)
|
`)
|
||||||
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), `concurrency: 1
|
writeImportFile(t, directory, filepath.Join("overlays", "testing.yml"), `concurrency: 1
|
||||||
equal: same
|
equal: same
|
||||||
limit: 7
|
limit: 7
|
||||||
items: [profile]
|
items: [overlay]
|
||||||
labels: {profile: retained}
|
labels: {overlay: 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)
|
||||||
}
|
}
|
||||||
return "RESOLVED_EXPLAIN_CANARY", true
|
return "RESOLVED_EXPLAIN_CANARY", true
|
||||||
}, Options[explanationConfig]{
|
}, Options[explanationConfig]{
|
||||||
Profile: "testing",
|
Overlay: "testing",
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{Path: Keys("concurrency"), Value: 2, Flag: "--concurrency"},
|
{Path: Keys("concurrency"), Value: 2, Flag: "--concurrency"},
|
||||||
{Path: Keys("equal"), Value: "same", Flag: "--equal"},
|
{Path: Keys("equal"), Value: "same", Flag: "--equal"},
|
||||||
@@ -130,7 +136,7 @@ dynamic: selected
|
|||||||
if got := explanationValues(concurrency); !reflect.DeepEqual(got, []string{"4\n", "8\n", "1\n", "2\n", "3\n"}) {
|
if got := explanationValues(concurrency); !reflect.DeepEqual(got, []string{"4\n", "8\n", "1\n", "2\n", "3\n"}) {
|
||||||
t.Fatalf("concurrency history = %#v", got)
|
t.Fatalf("concurrency history = %#v", got)
|
||||||
}
|
}
|
||||||
if got := historyValueLayers(concurrency); !reflect.DeepEqual(got, []Layer{LayerDefault, LayerBase, LayerProfile, LayerCLI, LayerResolution}) {
|
if got := historyValueLayers(concurrency); !reflect.DeepEqual(got, []Layer{LayerDefault, LayerBase, LayerOverlay, LayerCLI, LayerResolution}) {
|
||||||
t.Fatalf("concurrency layers = %#v", got)
|
t.Fatalf("concurrency layers = %#v", got)
|
||||||
}
|
}
|
||||||
if concurrency.History[4].Before.YAML != "2\n" || concurrency.History[4].Reasons[0] != "normalized\nconcurrency" ||
|
if concurrency.History[4].Before.YAML != "2\n" || concurrency.History[4].Reasons[0] != "normalized\nconcurrency" ||
|
||||||
@@ -149,7 +155,7 @@ dynamic: selected
|
|||||||
}
|
}
|
||||||
|
|
||||||
limit, _ := result.Explain(Keys("limit"))
|
limit, _ := result.Explain(Keys("limit"))
|
||||||
if got := explanationValues(limit); !reflect.DeepEqual(got, []string{"0\n", "wrong-before-profile\n", "7\n"}) {
|
if got := explanationValues(limit); !reflect.DeepEqual(got, []string{"0\n", "wrong-before-overlay\n", "7\n"}) {
|
||||||
t.Fatalf("overridden invalid input was not retained: %#v", got)
|
t.Fatalf("overridden invalid input was not retained: %#v", got)
|
||||||
}
|
}
|
||||||
removed, ok := result.Explain(Keys("dynamic", "retired"))
|
removed, ok := result.Explain(Keys("dynamic", "retired"))
|
||||||
@@ -182,7 +188,7 @@ dynamic: selected
|
|||||||
}
|
}
|
||||||
labels, _ := result.Explain(Keys("labels"))
|
labels, _ := result.Explain(Keys("labels"))
|
||||||
if len(labels.History) != 4 || !strings.Contains(labels.Effective.YAML, "default: retained") ||
|
if len(labels.History) != 4 || !strings.Contains(labels.Effective.YAML, "default: retained") ||
|
||||||
!strings.Contains(labels.Effective.YAML, "profile: retained") || !strings.Contains(labels.Effective.YAML, "cli: retained") {
|
!strings.Contains(labels.Effective.YAML, "overlay: retained") || !strings.Contains(labels.Effective.YAML, "cli: retained") {
|
||||||
t.Fatalf("composite mapping history = %#v", labels)
|
t.Fatalf("composite mapping history = %#v", labels)
|
||||||
}
|
}
|
||||||
if labels.Origin.Layer != LayerCLI || len(labels.Origin.Locations) != 3 {
|
if labels.Origin.Layer != LayerCLI || len(labels.Origin.Locations) != 3 {
|
||||||
@@ -258,7 +264,7 @@ dynamic: selected
|
|||||||
var invalidInput bytes.Buffer
|
var invalidInput bytes.Buffer
|
||||||
if err := result.WriteExplanation(&invalidInput, Keys("limit")); err != nil ||
|
if err := result.WriteExplanation(&invalidInput, Keys("limit")); err != nil ||
|
||||||
!strings.Contains(invalidInput.String(), "policy-validated input") ||
|
!strings.Contains(invalidInput.String(), "policy-validated input") ||
|
||||||
strings.Contains(invalidInput.String(), "wrong-before-profile\n source: layer=\"base\"\n before (canonical typed)") {
|
strings.Contains(invalidInput.String(), "wrong-before-overlay\n source: layer=\"base\"\n before (canonical typed)") {
|
||||||
t.Fatalf("historical representation rendering = %v\n%s", err, invalidInput.String())
|
t.Fatalf("historical representation rendering = %v\n%s", err, invalidInput.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,7 +291,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)
|
||||||
}
|
}
|
||||||
@@ -340,10 +346,10 @@ func TestExplanationReconcilesDefaultsAfterInputReplacement(t *testing.T) {
|
|||||||
if cli {
|
if cli {
|
||||||
opts.Overrides = []Override{{Path: Keys("m"), Value: map[string]any{"b": 2}, Flag: "--m"}}
|
opts.Overrides = []Override{{Path: Keys("m"), Value: map[string]any{"b": 2}, Flag: "--m"}}
|
||||||
} else {
|
} else {
|
||||||
writeImportFile(t, root, "profiles/p.yml", "m: {b: 2}\n")
|
writeImportFile(t, root, "overlays/p.yml", "m: {b: 2}\n")
|
||||||
opts.Profile = "p"
|
opts.Overlay = "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)
|
||||||
}
|
}
|
||||||
@@ -366,13 +372,117 @@ func TestExplanationReconcilesDefaultsAfterInputReplacement(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIntegerVersionInspectionRetainsInputAndReconcilesTypedValue(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
}
|
||||||
|
rule := &VersionRule{Path: Keys("version"), Check: func(version Version, _ *Validation) error {
|
||||||
|
if version.Text != "4" {
|
||||||
|
t.Errorf("version = %#v", version)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
options := Options[config]{Definition: Definition[config]{
|
||||||
|
Defaults: func() config { return config{Version: "4"} },
|
||||||
|
Version: rule,
|
||||||
|
}}
|
||||||
|
first, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if first.Config.Version != "4" || second.Config.Version != "4" {
|
||||||
|
t.Fatalf("configurations = %#v / %#v", first.Config, second.Config)
|
||||||
|
}
|
||||||
|
inputVersion, inputOK := documentAtPath(first.applications[0].value, Keys("version"))
|
||||||
|
finalVersion, finalOK := documentAtPath(first.final, Keys("version"))
|
||||||
|
if !inputOK || inputVersion.Kind != document.Number || inputVersion.Text != "4" ||
|
||||||
|
!finalOK || finalVersion.Kind != document.String || finalVersion.Text != "4" {
|
||||||
|
t.Fatalf("input/final = %#v / %#v", inputVersion, finalVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, ok := first.Metadata().Lookup(Keys("version"))
|
||||||
|
if !ok || !info.Explicit || info.Null || info.Origin.Layer != LayerBase || len(info.Origin.Locations) != 1 ||
|
||||||
|
info.Origin.Locations[0].SourceID != "source-1" || info.Origin.Locations[0].Line != 1 {
|
||||||
|
t.Fatalf("version metadata = %#v, %v", info, ok)
|
||||||
|
}
|
||||||
|
explanation, ok := first.Explain(Keys("version"))
|
||||||
|
if !ok || explanation.Effective.YAML != "'4'\n" {
|
||||||
|
t.Fatalf("version explanation = %#v, %v", explanation, ok)
|
||||||
|
}
|
||||||
|
foundInput, foundDecoding := false, false
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if change.After.YAML == "4\n" && change.Origin.Layer == LayerBase {
|
||||||
|
foundInput = true
|
||||||
|
}
|
||||||
|
if change.Before.YAML == "4\n" && change.After.YAML == "'4'\n" && change.Origin.Operation == ChangeDecoding {
|
||||||
|
foundDecoding = true
|
||||||
|
}
|
||||||
|
if change.Origin.Layer == LayerResolution {
|
||||||
|
t.Fatalf("conversion recorded as resolution: %#v", change)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundInput || !foundDecoding {
|
||||||
|
t.Fatalf("version history = %#v", explanation.History)
|
||||||
|
}
|
||||||
|
paths := first.Metadata().HistoricalPaths()
|
||||||
|
foundPath := false
|
||||||
|
for _, path := range paths {
|
||||||
|
if path.String() == "/version" {
|
||||||
|
foundPath = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundPath || len(first.Metadata().History(Keys("version"))) < 2 {
|
||||||
|
t.Fatalf("historical version paths/history = %#v / %#v", paths, first.Metadata().History(Keys("version")))
|
||||||
|
}
|
||||||
|
|
||||||
|
var effective strings.Builder
|
||||||
|
if err := first.WriteEffective(&effective); err != nil || effective.String() != "version: '4'\n" {
|
||||||
|
t.Fatalf("effective output = %q, %v", effective.String(), err)
|
||||||
|
}
|
||||||
|
first.Config.Version = "mutated"
|
||||||
|
effective.Reset()
|
||||||
|
if err := first.WriteEffective(&effective); err != nil || effective.String() != "version: '4'\n" || second.Config.Version != "4" {
|
||||||
|
t.Fatalf("detached results = %q / %#v, %v", effective.String(), second.Config, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSensitiveIntegerVersionStaysRedactedWithoutSecretLookup(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
}
|
||||||
|
lookups := 0
|
||||||
|
result, err := testLoadWithSecretLookup(Bytes("version.yml", []byte("version: 987654321\n")), func(string) (string, bool) {
|
||||||
|
lookups++
|
||||||
|
return "CREDENTIAL_CANARY", true
|
||||||
|
}, Options[config]{Definition: Definition[config]{
|
||||||
|
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
|
||||||
|
Secret: func(path Path) bool { return path.String() == "/version" },
|
||||||
|
}})
|
||||||
|
if err != nil || result.Config.Version != "987654321" || lookups != 0 {
|
||||||
|
t.Fatalf("result = %#v, lookups = %d, err = %v", result, lookups, err)
|
||||||
|
}
|
||||||
|
var effective strings.Builder
|
||||||
|
if err := result.WriteEffective(&effective); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explanation, ok := result.Explain(Keys("version"))
|
||||||
|
formatted := effective.String() + fmt.Sprintf("%#v %#v", explanation, result.Metadata().History(Keys("version")))
|
||||||
|
if !ok || !explanation.Effective.Redacted || strings.Contains(formatted, "987654321") || strings.Contains(formatted, "CREDENTIAL_CANARY") {
|
||||||
|
t.Fatalf("sensitive inspection = %q / %#v", effective.String(), explanation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExplanationReplaysResolutionAdditionsAndOverlappingIntent(t *testing.T) {
|
func TestExplanationReplaysResolutionAdditionsAndOverlappingIntent(t *testing.T) {
|
||||||
type cfg struct {
|
type cfg struct {
|
||||||
M map[string]any `yaml:"m"`
|
M map[string]any `yaml:"m"`
|
||||||
}
|
}
|
||||||
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"))
|
||||||
121
internal/engine/filesystem.go
Normal file
121
internal/engine/filesystem.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource) *Diagnostic {
|
||||||
|
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
|
||||||
|
if diagnostic != nil {
|
||||||
|
return diagnostic
|
||||||
|
}
|
||||||
|
root, err := os.OpenRoot(rootPath)
|
||||||
|
if err != nil {
|
||||||
|
value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
||||||
|
value.Primary = nil
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
if err := root.Close(); err != nil {
|
||||||
|
value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic {
|
||||||
|
switch source.kind {
|
||||||
|
case sourceNone:
|
||||||
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "NoSource cannot use filesystem context")}
|
||||||
|
case sourceFile:
|
||||||
|
if context.BaseDir != "" {
|
||||||
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")}
|
||||||
|
}
|
||||||
|
case sourceBytes, sourceReader:
|
||||||
|
if context.BaseDir == "" || context.Root == "" {
|
||||||
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "byte and reader filesystem context requires base and root directories")}
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) {
|
||||||
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "byte and reader filesystem base and root directories must be absolute")}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func importFilesystem(source Source, context *FilesystemContext, main parsedSource) (rootPath, basePath, mainPath string, diagnostic *Diagnostic) {
|
||||||
|
if main.source == nil || main.document == nil {
|
||||||
|
value := importDiagnostic(CodeImportInvalid, "imports require a selected configuration source", nil)
|
||||||
|
return "", "", "", &value
|
||||||
|
}
|
||||||
|
|
||||||
|
if source.kind == sourceFile {
|
||||||
|
if context != nil && context.BaseDir != "" {
|
||||||
|
value := optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")
|
||||||
|
return "", "", "", &value
|
||||||
|
}
|
||||||
|
canonical, err := canonicalExistingPath(source.path)
|
||||||
|
if err != nil {
|
||||||
|
value := importDiagnostic(CodeImportInvalid, "selected source path could not be canonicalized", nil)
|
||||||
|
return "", "", "", &value
|
||||||
|
}
|
||||||
|
mainPath = canonical
|
||||||
|
basePath = filepath.Dir(canonical)
|
||||||
|
rootPath = basePath
|
||||||
|
if context != nil && context.Root != "" {
|
||||||
|
rootPath, err = canonicalExistingPath(context.Root)
|
||||||
|
if err != nil {
|
||||||
|
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
||||||
|
return "", "", "", &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if context == nil || context.BaseDir == "" || context.Root == "" {
|
||||||
|
value := optionDiagnostic(CodeOptionsInvalid, "byte and reader imports require absolute base and root directories")
|
||||||
|
return "", "", "", &value
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) {
|
||||||
|
value := optionDiagnostic(CodeOptionsInvalid, "byte and reader import base and root directories must be absolute")
|
||||||
|
return "", "", "", &value
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
basePath, err = canonicalExistingPath(context.BaseDir)
|
||||||
|
if err != nil {
|
||||||
|
value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil)
|
||||||
|
return "", "", "", &value
|
||||||
|
}
|
||||||
|
rootPath, err = canonicalExistingPath(context.Root)
|
||||||
|
if err != nil {
|
||||||
|
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
||||||
|
return "", "", "", &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !containedPath(rootPath, basePath) || (mainPath != "" && !containedPath(rootPath, mainPath)) {
|
||||||
|
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
|
||||||
|
return "", "", "", &value
|
||||||
|
}
|
||||||
|
for _, candidate := range []string{rootPath, basePath} {
|
||||||
|
info, err := os.Stat(candidate)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
value := importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)
|
||||||
|
return "", "", "", &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rootPath, basePath, mainPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalExistingPath(name string) (string, error) {
|
||||||
|
absolute, err := filepath.Abs(name)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.EvalSymlinks(filepath.Clean(absolute))
|
||||||
|
}
|
||||||
|
|
||||||
|
func containedPath(root, target string) bool {
|
||||||
|
relative, err := filepath.Rel(root, target)
|
||||||
|
if err != nil || filepath.IsAbs(relative) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
@@ -10,13 +10,13 @@ import (
|
|||||||
|
|
||||||
func TestLoadGlobImportsUseLexicalDeclarationOrderAndNestedDFS(t *testing.T) {
|
func TestLoadGlobImportsUseLexicalDeclarationOrderAndNestedDFS(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- glob: fragments/*.yml
|
- glob: fragments/*.yml
|
||||||
- path: after.yml
|
- path: after.yml
|
||||||
items: {root: root}
|
items: {root: root}
|
||||||
`)
|
`)
|
||||||
aPath := writeImportFile(t, directory, "fragments/a.yml", `_configkit:
|
aPath := writeImportFile(t, directory, "fragments/a.yml", `configkit:
|
||||||
imports: [{path: nested/child.yml}]
|
imports: [{path: nested/child.yml}]
|
||||||
items: {alpha: alpha}
|
items: {alpha: alpha}
|
||||||
`)
|
`)
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ items: {alpha: alpha}
|
|||||||
|
|
||||||
func TestLoadGlobImportsSupportEscapesClassesAndExplicitConfD(t *testing.T) {
|
func TestLoadGlobImportsSupportEscapesClassesAndExplicitConfD(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- glob: 'conf.d/literal\*.yml'
|
- glob: 'conf.d/literal\*.yml'
|
||||||
- glob: 'conf.d/item-[ab].yml'
|
- glob: 'conf.d/item-[ab].yml'
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -126,7 +126,7 @@ func TestLoadGlobImportsPreserveIdentityAndFileRules(t *testing.T) {
|
|||||||
t.Run("overlapping declarations", func(t *testing.T) {
|
t.Run("overlapping declarations", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
writeImportFile(t, directory, "conf/value.yml", "items: {value: value}\n")
|
writeImportFile(t, directory, "conf/value.yml", "items: {value: value}\n")
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- glob: conf/*.yml
|
- glob: conf/*.yml
|
||||||
- glob: conf/value.*
|
- glob: conf/value.*
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -229,11 +229,11 @@ func TestGlobTraversalBoundsNonmatchesAndAcceptedCandidates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFilesystemContextIsValidatedWithoutImportsOrActiveProfile(t *testing.T) {
|
func TestFilesystemContextIsValidatedWithoutImportsOrActiveOverlay(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},
|
Overlay: "default", Filesystem: &FilesystemContext{Root: directory},
|
||||||
})
|
})
|
||||||
if err != nil || result.Config.Items["root"] != "root" {
|
if err != nil || result.Config.Items["root"] != "root" {
|
||||||
t.Fatalf("file filesystem context = %#v, %v", result, err)
|
t.Fatalf("file filesystem context = %#v, %v", result, err)
|
||||||
@@ -242,20 +242,20 @@ 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]{Overlay: "default"})
|
||||||
if err != nil || defaultsOnly == nil {
|
if err != nil || defaultsOnly == nil {
|
||||||
t.Fatalf("default profile with NoSource = %#v, %v", defaultsOnly, err)
|
t.Fatalf("default overlay with NoSource = %#v, %v", defaultsOnly, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -269,5 +269,5 @@ func writeGlobRoot(t *testing.T, directory, pattern string, optional bool) strin
|
|||||||
if optional {
|
if optional {
|
||||||
optionalLine = "\n optional: true"
|
optionalLine = "\n optional: true"
|
||||||
}
|
}
|
||||||
return writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - glob: '"+pattern+"'"+optionalLine+"\n")
|
return writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - glob: '"+pattern+"'"+optionalLine+"\n")
|
||||||
}
|
}
|
||||||
276
internal/engine/history.go
Normal file
276
internal/engine/history.go
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
const historyNodeLimit = 4_000_000
|
||||||
|
|
||||||
|
// History applications borrow immutable policy-validated input trees. Metadata
|
||||||
|
// records answer presence and origin queries; replay reconstructs value changes.
|
||||||
|
// Replay reconciles with canonical decoding before domain resolution so both
|
||||||
|
// contracts agree on the effective result without retaining a tree per field.
|
||||||
|
type historyApplication struct {
|
||||||
|
layer Layer
|
||||||
|
path Path
|
||||||
|
value *document.Node
|
||||||
|
origin Origin
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneHistoryApplications(values []historyApplication) []historyApplication {
|
||||||
|
if values == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := make([]historyApplication, len(values))
|
||||||
|
for index, value := range values {
|
||||||
|
cloned[index] = historyApplication{
|
||||||
|
layer: value.layer, path: value.path.clone(), value: value.value,
|
||||||
|
origin: cloneOrigin(value.origin),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationOperationPath(application historyApplication, query Path) (Path, bool) {
|
||||||
|
if !application.path.valid() || application.value == nil {
|
||||||
|
return Path{}, false
|
||||||
|
}
|
||||||
|
if application.path.String() != "" {
|
||||||
|
if pathsIntersect(application.path, query) {
|
||||||
|
return application.path.clone(), true
|
||||||
|
}
|
||||||
|
return Path{}, false
|
||||||
|
}
|
||||||
|
if len(query.segments) == 0 {
|
||||||
|
return Path{}, true
|
||||||
|
}
|
||||||
|
for length := 1; length <= len(query.segments); length++ {
|
||||||
|
prefix := Path{segments: clonePathSegments(query.segments[:length], 0)}
|
||||||
|
node, present := documentAtPath(application.value, prefix)
|
||||||
|
if !present {
|
||||||
|
return Path{}, false
|
||||||
|
}
|
||||||
|
if length == len(query.segments) || node.Kind != document.Mapping {
|
||||||
|
return prefix, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Path{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func historyApplicationOrigin(application historyApplication, operationPath Path) Origin {
|
||||||
|
if application.origin.Layer != "" {
|
||||||
|
return cloneOrigin(application.origin)
|
||||||
|
}
|
||||||
|
origin := Origin{Layer: application.layer}
|
||||||
|
relative, ok := relativeHistoryPath(application.path, operationPath)
|
||||||
|
if !ok {
|
||||||
|
return origin
|
||||||
|
}
|
||||||
|
if node, present := documentAtPath(application.value, relative); present {
|
||||||
|
origin.Locations = []Location{publicLocation(node.Location)}
|
||||||
|
}
|
||||||
|
return origin
|
||||||
|
}
|
||||||
|
|
||||||
|
func relativeHistoryPath(root, path Path) (Path, bool) {
|
||||||
|
if !pathContains(root, path) {
|
||||||
|
return Path{}, false
|
||||||
|
}
|
||||||
|
return Path{segments: clonePathSegments(path.segments[len(root.segments):], 0)}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathsIntersect(left, right Path) bool {
|
||||||
|
return pathContains(left, right) || pathContains(right, left)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyHistoryApplication(root **document.Node, application historyApplication) {
|
||||||
|
if application.path.String() == "" {
|
||||||
|
mergeHistoryDocument(root, application.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mergeHistoryAtPath(root, application.path, application.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeHistoryDocument(target **document.Node, incoming *document.Node) {
|
||||||
|
if incoming == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if *target == nil || (*target).Kind != document.Mapping || incoming.Kind != document.Mapping {
|
||||||
|
*target = document.Clone(incoming)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, entry := range incoming.Mapping {
|
||||||
|
position := documentEntryIndex((*target).Mapping, entry.Key)
|
||||||
|
if position < 0 {
|
||||||
|
cloned := entry
|
||||||
|
cloned.Value = document.Clone(entry.Value)
|
||||||
|
(*target).Mapping = append((*target).Mapping, cloned)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mergeHistoryDocument(&(*target).Mapping[position].Value, entry.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node) {
|
||||||
|
if root == nil || *root == nil || len(path.segments) == 0 {
|
||||||
|
mergeHistoryDocument(root, incoming)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current := *root
|
||||||
|
for index, segment := range path.segments {
|
||||||
|
if current.Kind != document.Mapping || segment.kind == pathSegmentIndex {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
position := documentEntryIndex(current.Mapping, segment.key)
|
||||||
|
if index == len(path.segments)-1 {
|
||||||
|
if position < 0 {
|
||||||
|
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: document.Clone(incoming)})
|
||||||
|
} else {
|
||||||
|
mergeHistoryDocument(¤t.Mapping[position].Value, incoming)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if position < 0 {
|
||||||
|
child := &document.Node{Kind: document.Mapping}
|
||||||
|
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: child})
|
||||||
|
current = child
|
||||||
|
} else {
|
||||||
|
current = current.Mapping[position].Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolution records describe one before/final transformation, not assignment
|
||||||
|
// order. A parent already carries its descendants' final values; merge overlapping
|
||||||
|
// intent into that event rather than replay a child assignment a second time.
|
||||||
|
func coalescedResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSnapshot {
|
||||||
|
groups := make([]resolutionSnapshot, 0, len(snapshots))
|
||||||
|
for _, snapshot := range snapshots {
|
||||||
|
merged := false
|
||||||
|
for i := range groups {
|
||||||
|
if !pathContains(groups[i].change.Path, snapshot.change.Path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, reason := range snapshot.change.Reasons {
|
||||||
|
groups[i].change.Reasons = appendUniqueString(groups[i].change.Reasons, reason)
|
||||||
|
}
|
||||||
|
for _, dependency := range snapshot.change.Dependencies {
|
||||||
|
groups[i].change.Dependencies = appendUniquePath(groups[i].change.Dependencies, dependency)
|
||||||
|
}
|
||||||
|
if snapshot.change.Origin.Operation == ChangeNormalization {
|
||||||
|
groups[i].change.Origin.Operation = ChangeNormalization
|
||||||
|
}
|
||||||
|
merged = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !merged {
|
||||||
|
snapshot.change = cloneChange(snapshot.change)
|
||||||
|
groups = append(groups, snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceHistoryPath(root **document.Node, path Path, replacement *document.Node) {
|
||||||
|
if root == nil || *root == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(path.segments) == 0 {
|
||||||
|
*root = document.Clone(replacement)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current := *root
|
||||||
|
for index, segment := range path.segments {
|
||||||
|
last := index == len(path.segments)-1
|
||||||
|
switch current.Kind {
|
||||||
|
case document.Mapping:
|
||||||
|
if segment.kind == pathSegmentIndex {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
position := documentEntryIndex(current.Mapping, segment.key)
|
||||||
|
if position < 0 {
|
||||||
|
if replacement == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
child := &document.Node{Kind: document.Mapping}
|
||||||
|
if last {
|
||||||
|
child = document.Clone(replacement)
|
||||||
|
}
|
||||||
|
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: child})
|
||||||
|
if last {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current = child
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if last {
|
||||||
|
if replacement == nil {
|
||||||
|
current.Mapping = append(current.Mapping[:position], current.Mapping[position+1:]...)
|
||||||
|
} else {
|
||||||
|
current.Mapping[position].Value = document.Clone(replacement)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current = current.Mapping[position].Value
|
||||||
|
case document.Sequence:
|
||||||
|
position, ok := schemaIndex(segment)
|
||||||
|
if !ok || position >= len(current.Sequence) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if last {
|
||||||
|
if replacement != nil {
|
||||||
|
current.Sequence[position] = document.Clone(replacement)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current = current.Sequence[position]
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func historyNodesWithinLimit(baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool {
|
||||||
|
return historyNodesWithin(historyNodeLimit, baseline, input, final, applications, resolutions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func historyNodesWithin(limit int, baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool {
|
||||||
|
seen := make(map[*document.Node]bool)
|
||||||
|
count := 0
|
||||||
|
var visit func(*document.Node) bool
|
||||||
|
visit = func(node *document.Node) bool {
|
||||||
|
if node == nil || seen[node] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
seen[node] = true
|
||||||
|
count++
|
||||||
|
if count > limit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
if !visit(entry.Value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, child := range node.Sequence {
|
||||||
|
if !visit(child) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !visit(baseline) || !visit(input) || !visit(final) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, application := range applications {
|
||||||
|
if !visit(application.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, resolution := range resolutions {
|
||||||
|
if !visit(resolution.before) || !visit(resolution.after) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
324
internal/engine/imports.go
Normal file
324
internal/engine/imports.go
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxImportDepth = 64
|
||||||
|
maxImportSources = 1024
|
||||||
|
maxGlobEntries = 1_000_000
|
||||||
|
)
|
||||||
|
|
||||||
|
type importLimits struct {
|
||||||
|
depth int
|
||||||
|
sources int
|
||||||
|
bytes int64
|
||||||
|
nodes int
|
||||||
|
entries int
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultImportLimits() importLimits {
|
||||||
|
return importLimits{
|
||||||
|
depth: maxImportDepth, sources: maxImportSources,
|
||||||
|
bytes: maxTotalBytes, nodes: maxDocumentNodes, entries: maxGlobEntries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type importRoute struct {
|
||||||
|
canonical string
|
||||||
|
locations []Location
|
||||||
|
}
|
||||||
|
|
||||||
|
type importTraversal struct {
|
||||||
|
rootPath string
|
||||||
|
root *os.Root
|
||||||
|
limits importLimits
|
||||||
|
rule *VersionRule
|
||||||
|
|
||||||
|
documents []*document.Node
|
||||||
|
sources []SourceInfo
|
||||||
|
active map[string]int
|
||||||
|
visited map[string]importRoute
|
||||||
|
stack []importRoute
|
||||||
|
usedBytes int64
|
||||||
|
usedNodes int
|
||||||
|
usedEntries int
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadImportedDocuments(source Source, context *FilesystemContext, main parsedSource, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
|
rootPath, basePath, mainPath, diagnostic := importFilesystem(source, context, main)
|
||||||
|
if diagnostic != nil {
|
||||||
|
return nil, sourceInfos(main.source), []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
root, err := os.OpenRoot(rootPath)
|
||||||
|
if err != nil {
|
||||||
|
diagnostic := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
||||||
|
diagnostic.Primary = nil
|
||||||
|
return nil, sourceInfos(main.source), []Diagnostic{diagnostic}
|
||||||
|
}
|
||||||
|
|
||||||
|
mainInfo := cloneSourceInfo(*main.source)
|
||||||
|
if mainPath != "" {
|
||||||
|
mainInfo.Path = mainPath
|
||||||
|
}
|
||||||
|
state := importTraversal{
|
||||||
|
rootPath: rootPath, root: root, limits: limits, rule: rule,
|
||||||
|
documents: make([]*document.Node, 0, 1),
|
||||||
|
sources: []SourceInfo{mainInfo},
|
||||||
|
active: make(map[string]int), visited: make(map[string]importRoute),
|
||||||
|
usedBytes: main.bytes, usedNodes: countInputNodes(main.document),
|
||||||
|
}
|
||||||
|
if diagnostic := state.checkInitialLimits(); diagnostic != nil {
|
||||||
|
_ = root.Close()
|
||||||
|
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
|
||||||
|
mainIdentity := mainPath
|
||||||
|
if mainIdentity == "" {
|
||||||
|
mainIdentity = "<selected-source>"
|
||||||
|
}
|
||||||
|
mainRoute := importRoute{canonical: mainIdentity}
|
||||||
|
state.active[mainIdentity] = 0
|
||||||
|
state.visited[mainIdentity] = mainRoute
|
||||||
|
state.stack = append(state.stack, mainRoute)
|
||||||
|
body, declarations, diagnostics := splitImportDirectives(main.document)
|
||||||
|
if len(diagnostics) == 0 {
|
||||||
|
state.documents = append(state.documents, body)
|
||||||
|
diagnostics = state.walkDeclarations(basePath, declarations, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
closeErr := root.Close()
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, cloneSourceInfos(state.sources), diagnostics
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)}
|
||||||
|
}
|
||||||
|
composed, diagnostics := composeDocuments(state.documents...)
|
||||||
|
if composed != nil {
|
||||||
|
composed.inputBytes = state.usedBytes
|
||||||
|
composed.inputNodes = state.usedNodes
|
||||||
|
}
|
||||||
|
return composed, cloneSourceInfos(state.sources), diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *importTraversal) checkInitialLimits() *Diagnostic {
|
||||||
|
if s.limits.sources < 1 {
|
||||||
|
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
if s.usedBytes > s.limits.bytes {
|
||||||
|
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate byte limit", nil)
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
if s.usedNodes > s.limits.nodes {
|
||||||
|
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *importTraversal) walkDeclarations(basePath string, declarations []importDeclaration, depth int) []Diagnostic {
|
||||||
|
for _, declaration := range declarations {
|
||||||
|
if declaration.glob != "" {
|
||||||
|
matches, diagnostics := s.expandImportGlob(basePath, declaration)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
if len(matches) == 0 {
|
||||||
|
if declaration.optional {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
location := declaration.location
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportMissing, "required import glob matched no files", &location)}
|
||||||
|
}
|
||||||
|
for _, match := range matches {
|
||||||
|
matched := declaration
|
||||||
|
matched.path = match
|
||||||
|
matched.glob = ""
|
||||||
|
if diagnostics := s.visitPath(basePath, matched, depth+1); len(diagnostics) != 0 {
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if diagnostics := s.visitPath(basePath, declaration, depth+1); len(diagnostics) != 0 {
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *importTraversal) visitPath(basePath string, declaration importDeclaration, depth int) []Diagnostic {
|
||||||
|
location := declaration.location
|
||||||
|
if depth > s.limits.depth {
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the active import-depth limit", &location)}
|
||||||
|
}
|
||||||
|
if !validImportPath(declaration.path) {
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportInvalid, "import path must be a nonempty slash-relative path without a network scheme", &location)}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate := filepath.Join(basePath, filepath.FromSlash(path.Clean(declaration.path)))
|
||||||
|
if !containedPath(s.rootPath, filepath.Clean(candidate)) {
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target is outside the configuration root", &location)}
|
||||||
|
}
|
||||||
|
canonical, err := canonicalExistingPath(candidate)
|
||||||
|
if err != nil {
|
||||||
|
if declaration.optional && errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
code := CodeImportInvalid
|
||||||
|
message := "import target could not be canonicalized"
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
code, message = CodeImportMissing, "required import target does not exist"
|
||||||
|
} else if errors.Is(err, fs.ErrPermission) {
|
||||||
|
message = "import target cannot be accessed"
|
||||||
|
}
|
||||||
|
return []Diagnostic{importDiagnostic(code, message, &location)}
|
||||||
|
}
|
||||||
|
if !containedPath(s.rootPath, canonical) {
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target resolves outside the configuration root", &location)}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentRoute := s.currentRoute(location)
|
||||||
|
if activeIndex, ok := s.active[canonical]; ok {
|
||||||
|
cycle := append([]importRoute(nil), s.stack[activeIndex:]...)
|
||||||
|
return []Diagnostic{importCycleDiagnostic(canonical, location, cycle)}
|
||||||
|
}
|
||||||
|
if previous, ok := s.visited[canonical]; ok {
|
||||||
|
return []Diagnostic{importRepeatedDiagnostic(location, previous, currentRoute)}
|
||||||
|
}
|
||||||
|
if len(s.sources)+1 > s.limits.sources {
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", &location)}
|
||||||
|
}
|
||||||
|
|
||||||
|
relative, err := filepath.Rel(s.rootPath, canonical)
|
||||||
|
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target could not be addressed inside the configuration root", &location)}
|
||||||
|
}
|
||||||
|
sourceID := "source-" + strconv.Itoa(len(s.sources)+1)
|
||||||
|
info := SourceInfo{ID: sourceID, Label: declaration.path, Path: canonical, Layer: LayerBase, ImportedFrom: &location}
|
||||||
|
s.sources = append(s.sources, info)
|
||||||
|
data, skipped, diagnostic := s.readImport(relative, sourceID, declaration.optional)
|
||||||
|
if diagnostic != nil {
|
||||||
|
return []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
if skipped {
|
||||||
|
s.sources = s.sources[:len(s.sources)-1]
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
root, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{
|
||||||
|
Depth: maxDocumentDepth, Nodes: maxDocumentNodes,
|
||||||
|
})
|
||||||
|
if len(issues) != 0 {
|
||||||
|
return parserDiagnostics(sourceID, issues)
|
||||||
|
}
|
||||||
|
nodeCount := countInputNodes(root)
|
||||||
|
if s.usedNodes+nodeCount > s.limits.nodes {
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)}
|
||||||
|
}
|
||||||
|
s.usedNodes += nodeCount
|
||||||
|
|
||||||
|
body, declarations, diagnostics := splitImportDirectives(root)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
if versionNode := fragmentVersionClaim(body, s.rule); versionNode != nil {
|
||||||
|
versionLocation := publicLocation(versionNode.Location)
|
||||||
|
return []Diagnostic{{
|
||||||
|
Code: CodeImportVersion, Severity: SeverityError, Path: s.rule.Path.clone(),
|
||||||
|
Message: "imported fragments cannot contribute to the schema-version path", Primary: &versionLocation,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
route := importRoute{canonical: canonical, locations: currentRoute.locations}
|
||||||
|
s.documents = append(s.documents, body)
|
||||||
|
s.active[canonical] = len(s.stack)
|
||||||
|
s.visited[canonical] = route
|
||||||
|
s.stack = append(s.stack, route)
|
||||||
|
diagnostics = s.walkDeclarations(filepath.Dir(canonical), declarations, depth)
|
||||||
|
delete(s.active, canonical)
|
||||||
|
s.stack = s.stack[:len(s.stack)-1]
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *importTraversal) readImport(relative, sourceID string, optional bool) ([]byte, bool, *Diagnostic) {
|
||||||
|
remaining := s.limits.bytes - s.usedBytes
|
||||||
|
limit := min(int64(maxSourceBytes), remaining)
|
||||||
|
if limit < 0 {
|
||||||
|
limit = 0
|
||||||
|
}
|
||||||
|
data, failure := readRegularFile(relative, s.root.Stat, s.root.Open, limit)
|
||||||
|
if failure != nil {
|
||||||
|
if optional && failure.missingTarget {
|
||||||
|
return nil, true, nil
|
||||||
|
}
|
||||||
|
value := sourceReadDiagnostic(failure, sourceID, CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit")
|
||||||
|
return nil, false, &value
|
||||||
|
}
|
||||||
|
s.usedBytes += int64(len(data))
|
||||||
|
return data, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *importTraversal) currentRoute(location Location) importRoute {
|
||||||
|
locations := make([]Location, 0, len(s.stack)+1)
|
||||||
|
for _, route := range s.stack {
|
||||||
|
if len(route.locations) != 0 {
|
||||||
|
locations = append(locations, route.locations[len(route.locations)-1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
locations = append(locations, location)
|
||||||
|
return importRoute{locations: locations}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceInfos(source *SourceInfo) []SourceInfo {
|
||||||
|
if source == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []SourceInfo{cloneSourceInfo(*source)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func importDiagnostic(code Code, message string, location *Location) Diagnostic {
|
||||||
|
return Diagnostic{Code: code, Severity: SeverityError, Message: message, Primary: cloneLocation(location)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func importCycleDiagnostic(target string, location Location, cycle []importRoute) Diagnostic {
|
||||||
|
paths := make([]string, 0, len(cycle)+1)
|
||||||
|
related := make([]Location, 0, len(cycle))
|
||||||
|
for _, route := range cycle {
|
||||||
|
paths = append(paths, route.canonical)
|
||||||
|
if len(route.locations) != 0 {
|
||||||
|
related = append(related, route.locations[len(route.locations)-1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paths = append(paths, target)
|
||||||
|
return Diagnostic{
|
||||||
|
Code: CodeImportCycle, Severity: SeverityError,
|
||||||
|
Message: "import cycle detected: " + strings.Join(paths, " -> "),
|
||||||
|
Primary: &location, Related: related,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func importRepeatedDiagnostic(location Location, previous, current importRoute) Diagnostic {
|
||||||
|
related := append([]Location(nil), previous.locations...)
|
||||||
|
if len(current.locations) > 1 {
|
||||||
|
related = append(related, current.locations[:len(current.locations)-1]...)
|
||||||
|
}
|
||||||
|
return Diagnostic{
|
||||||
|
Code: CodeImportRepeated, Severity: SeverityError,
|
||||||
|
Message: "configuration source is included by more than one import route",
|
||||||
|
Primary: &location, Related: related,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -22,16 +22,37 @@ type importTestConfig struct {
|
|||||||
Items map[string]string `yaml:"items"`
|
Items map[string]string `yaml:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadFormerDirectiveKeyIsApplicationData(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Legacy struct {
|
||||||
|
Imports []struct {
|
||||||
|
Path string `yaml:"path"`
|
||||||
|
} `yaml:"imports"`
|
||||||
|
} `yaml:"_configkit"`
|
||||||
|
}
|
||||||
|
for _, imports := range []bool{false, true} {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - path: nonexistent.yml\n")
|
||||||
|
result, err := testLoad[config](File(mainPath), Options[config]{Imports: imports})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Imports=%v: %v", imports, err)
|
||||||
|
}
|
||||||
|
if len(result.Config.Legacy.Imports) != 1 || result.Config.Legacy.Imports[0].Path != "nonexistent.yml" || len(result.Sources()) != 1 {
|
||||||
|
t.Fatalf("Imports=%v: former directive was not retained as application data: %#v", imports, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadTraversesPathImportsDepthFirstAndComposesOrigins(t *testing.T) {
|
func TestLoadTraversesPathImportsDepthFirstAndComposesOrigins(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: first.yml
|
- path: first.yml
|
||||||
- path: second.yml
|
- path: second.yml
|
||||||
settings:
|
settings:
|
||||||
root: root
|
root: root
|
||||||
`)
|
`)
|
||||||
firstPath := writeImportFile(t, directory, "first.yml", `_configkit:
|
firstPath := writeImportFile(t, directory, "first.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: nested/third.yml
|
- path: nested/third.yml
|
||||||
settings:
|
settings:
|
||||||
@@ -40,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)
|
||||||
}
|
}
|
||||||
@@ -68,10 +89,22 @@ settings:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadImportsFromProfilesDirectoryAsBaseInput(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: profiles/shared.yml}]}\n")
|
||||||
|
writeImportFile(t, directory, "profiles/shared.yml", "settings: {root: imported}\n")
|
||||||
|
|
||||||
|
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||||
|
if err != nil || result.Config.Settings.Root != "imported" || len(result.Sources()) != 2 ||
|
||||||
|
result.Sources()[1].Layer != LayerBase {
|
||||||
|
t.Fatalf("profiles import = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) {
|
func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) {
|
||||||
t.Run("all duplicate claimants", func(t *testing.T) {
|
t.Run("all duplicate claimants", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: one.yml
|
- path: one.yml
|
||||||
- path: two.yml
|
- path: two.yml
|
||||||
@@ -92,12 +125,12 @@ func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
fragment string
|
fragment string
|
||||||
}{
|
}{
|
||||||
{name: "exact", fragment: "settings:\n version: v2\n"},
|
{name: "exact integer", fragment: "settings:\n version: 4\n"},
|
||||||
{name: "ancestor scalar", fragment: "settings: blocked\n"},
|
{name: "ancestor scalar", fragment: "settings: blocked\n"},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - path: fragment.yml\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - path: fragment.yml\n")
|
||||||
writeImportFile(t, directory, "fragment.yml", test.fragment)
|
writeImportFile(t, directory, "fragment.yml", test.fragment)
|
||||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true, Definition: Definition[importTestConfig]{Version: versionRule}}, CodeImportVersion)
|
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true, Definition: Definition[importTestConfig]{Version: versionRule}}, CodeImportVersion)
|
||||||
diagnostic := loadError.Diagnostics()[0]
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
@@ -114,15 +147,15 @@ func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) {
|
|||||||
body string
|
body string
|
||||||
code Code
|
code Code
|
||||||
}{
|
}{
|
||||||
{name: "directive kind", body: "_configkit: []\n", code: CodeImportInvalid},
|
{name: "directive kind", body: "configkit: []\n", code: CodeImportInvalid},
|
||||||
{name: "unknown directive field", body: "_configkit: {unknown: true}\n", code: CodeImportInvalid},
|
{name: "unknown directive field", body: "configkit: {unknown: true}\n", code: CodeImportInvalid},
|
||||||
{name: "imports kind", body: "_configkit: {imports: {}}\n", code: CodeImportInvalid},
|
{name: "imports kind", body: "configkit: {imports: {}}\n", code: CodeImportInvalid},
|
||||||
{name: "declaration kind", body: "_configkit: {imports: [file.yml]}\n", code: CodeImportInvalid},
|
{name: "declaration kind", body: "configkit: {imports: [file.yml]}\n", code: CodeImportInvalid},
|
||||||
{name: "missing target", body: "_configkit: {imports: [{optional: true}]}\n", code: CodeImportInvalid},
|
{name: "missing target", body: "configkit: {imports: [{optional: true}]}\n", code: CodeImportInvalid},
|
||||||
{name: "two targets", body: "_configkit: {imports: [{path: a.yml, glob: '*.yml'}]}\n", code: CodeImportInvalid},
|
{name: "two targets", body: "configkit: {imports: [{path: a.yml, glob: '*.yml'}]}\n", code: CodeImportInvalid},
|
||||||
{name: "unknown declaration field", body: "_configkit: {imports: [{path: a.yml, extra: true}]}\n", code: CodeImportInvalid},
|
{name: "unknown declaration field", body: "configkit: {imports: [{path: a.yml, extra: true}]}\n", code: CodeImportInvalid},
|
||||||
{name: "optional kind", body: "_configkit: {imports: [{path: a.yml, optional: 'yes'}]}\n", code: CodeImportInvalid},
|
{name: "optional kind", body: "configkit: {imports: [{path: a.yml, optional: 'yes'}]}\n", code: CodeImportInvalid},
|
||||||
{name: "nested directive", body: "nested: {_configkit: {}}\n", code: CodeImportInvalid},
|
{name: "nested directive", body: "nested: {configkit: {}}\n", code: CodeImportInvalid},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
@@ -136,13 +169,13 @@ func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) {
|
|||||||
func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) {
|
func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) {
|
||||||
t.Run("optional missing", func(t *testing.T) {
|
t.Run("optional missing", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: missing.yml
|
- path: missing.yml
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -172,7 +205,7 @@ settings: {root: retained}
|
|||||||
if test.setup != nil {
|
if test.setup != nil {
|
||||||
test.setup(t, directory)
|
test.setup(t, directory)
|
||||||
}
|
}
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - path: \""+test.path+"\"\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - path: \""+test.path+"\"\n")
|
||||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, test.code)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, test.code)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -181,9 +214,9 @@ settings: {root: retained}
|
|||||||
func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
|
func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
|
||||||
t.Run("cycle", func(t *testing.T) {
|
t.Run("cycle", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports: [{path: a.yml}]\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports: [{path: a.yml}]\n")
|
||||||
writeImportFile(t, directory, "a.yml", "_configkit:\n imports: [{path: b.yml}]\n")
|
writeImportFile(t, directory, "a.yml", "configkit:\n imports: [{path: b.yml}]\n")
|
||||||
writeImportFile(t, directory, "b.yml", "_configkit:\n imports: [{path: main.yml}]\n")
|
writeImportFile(t, directory, "b.yml", "configkit:\n imports: [{path: main.yml}]\n")
|
||||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportCycle)
|
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportCycle)
|
||||||
diagnostic := loadError.Diagnostics()[0]
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
if diagnostic.Primary == nil || len(diagnostic.Related) != 2 || !strings.Contains(diagnostic.Message, "main.yml") || !strings.Contains(diagnostic.Message, "a.yml") || !strings.Contains(diagnostic.Message, "b.yml") {
|
if diagnostic.Primary == nil || len(diagnostic.Related) != 2 || !strings.Contains(diagnostic.Message, "main.yml") || !strings.Contains(diagnostic.Message, "a.yml") || !strings.Contains(diagnostic.Message, "b.yml") {
|
||||||
@@ -193,9 +226,9 @@ func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("diamond", func(t *testing.T) {
|
t.Run("diamond", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports: [{path: left.yml}, {path: right.yml}]\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports: [{path: left.yml}, {path: right.yml}]\n")
|
||||||
writeImportFile(t, directory, "left.yml", "_configkit:\n imports: [{path: shared.yml}]\n")
|
writeImportFile(t, directory, "left.yml", "configkit:\n imports: [{path: shared.yml}]\n")
|
||||||
writeImportFile(t, directory, "right.yml", "_configkit:\n imports: [{path: shared.yml}]\n")
|
writeImportFile(t, directory, "right.yml", "configkit:\n imports: [{path: shared.yml}]\n")
|
||||||
writeImportFile(t, directory, "shared.yml", "settings: {root: shared}\n")
|
writeImportFile(t, directory, "shared.yml", "settings: {root: shared}\n")
|
||||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
|
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
|
||||||
diagnostic := loadError.Diagnostics()[0]
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
@@ -216,7 +249,7 @@ func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
|
|||||||
if err := os.Symlink("target.yml", filepath.Join(directory, "second-link.yml")); err != nil {
|
if err := os.Symlink("target.yml", filepath.Join(directory, "second-link.yml")); err != nil {
|
||||||
t.Skipf("symlinks unavailable: %v", err)
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
}
|
}
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: first-link.yml}, {path: second-link.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: first-link.yml}, {path: second-link.yml}]}\n")
|
||||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -231,8 +264,8 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
|
|||||||
if err := os.Symlink(target, filepath.Join(directory, "link.yml")); err != nil {
|
if err := os.Symlink(target, filepath.Join(directory, "link.yml")); err != nil {
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -245,7 +278,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
|
|||||||
if err := os.Symlink(target, filepath.Join(root, "escape.yml")); err != nil {
|
if err := os.Symlink(target, filepath.Join(root, "escape.yml")); err != nil {
|
||||||
t.Skipf("symlinks unavailable: %v", err)
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
}
|
}
|
||||||
mainPath := writeImportFile(t, root, "main.yml", "_configkit: {imports: [{path: escape.yml}]}\n")
|
mainPath := writeImportFile(t, root, "main.yml", "configkit: {imports: [{path: escape.yml}]}\n")
|
||||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -255,7 +288,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
|
|||||||
if err := os.Link(one, filepath.Join(directory, "two.yml")); err != nil {
|
if err := os.Link(one, filepath.Join(directory, "two.yml")); err != nil {
|
||||||
t.Skipf("hard links unavailable: %v", err)
|
t.Skipf("hard links unavailable: %v", err)
|
||||||
}
|
}
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
|
||||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeComposeConflict)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeComposeConflict)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -264,7 +297,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
|
|||||||
if err := os.Symlink("loop.yml", filepath.Join(directory, "loop.yml")); err != nil {
|
if err := os.Symlink("loop.yml", filepath.Join(directory, "loop.yml")); err != nil {
|
||||||
t.Skipf("symlinks unavailable: %v", err)
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
}
|
}
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: loop.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: loop.yml}]}\n")
|
||||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportInvalid)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportInvalid)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -272,7 +305,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
|
|||||||
func TestLoadByteAndReaderImportsRequireExplicitFilesystemContext(t *testing.T) {
|
func TestLoadByteAndReaderImportsRequireExplicitFilesystemContext(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
writeImportFile(t, directory, "fragment.yml", "settings: {first: imported}\n")
|
writeImportFile(t, directory, "fragment.yml", "settings: {first: imported}\n")
|
||||||
input := []byte("_configkit: {imports: [{path: fragment.yml}]}\n")
|
input := []byte("configkit: {imports: [{path: fragment.yml}]}\n")
|
||||||
context := &FilesystemContext{BaseDir: directory, Root: directory}
|
context := &FilesystemContext{BaseDir: directory, Root: directory}
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -282,7 +315,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)
|
||||||
}
|
}
|
||||||
@@ -295,9 +328,9 @@ func TestLoadByteAndReaderImportsRequireExplicitFilesystemContext(t *testing.T)
|
|||||||
|
|
||||||
func TestFileImportRootCanContainTheSelectedFileDirectory(t *testing.T) {
|
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" {
|
||||||
@@ -309,7 +342,7 @@ func TestFileImportRootCanContainTheSelectedFileDirectory(t *testing.T) {
|
|||||||
func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
|
func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
|
||||||
t.Run("parse", func(t *testing.T) {
|
t.Run("parse", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: broken.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: broken.yml}]}\n")
|
||||||
writeImportFile(t, directory, "broken.yml", "settings: [\n")
|
writeImportFile(t, directory, "broken.yml", "settings: [\n")
|
||||||
loadError := requireAnyLoadError(t, File(mainPath), Options[importTestConfig]{Imports: true})
|
loadError := requireAnyLoadError(t, File(mainPath), Options[importTestConfig]{Imports: true})
|
||||||
diagnostics := loadError.Diagnostics()
|
diagnostics := loadError.Diagnostics()
|
||||||
@@ -320,7 +353,7 @@ func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("decode", func(t *testing.T) {
|
t.Run("decode", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: unknown.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: unknown.yml}]}\n")
|
||||||
writeImportFile(t, directory, "unknown.yml", "unknown: value\n")
|
writeImportFile(t, directory, "unknown.yml", "unknown: value\n")
|
||||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeDecodeUnknownField)
|
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeDecodeUnknownField)
|
||||||
diagnostic := loadError.Diagnostics()[0]
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
@@ -332,7 +365,7 @@ func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
|
|||||||
|
|
||||||
func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
|
func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "outside.yml", "_configkit: {imports: [{path: ../missing.yml, optional: true}]}\n")
|
mainPath := writeImportFile(t, directory, "outside.yml", "configkit: {imports: [{path: ../missing.yml, optional: true}]}\n")
|
||||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
|
||||||
|
|
||||||
locked := writeImportFile(t, directory, "locked.yml", "settings: {root: locked}\n")
|
locked := writeImportFile(t, directory, "locked.yml", "settings: {root: locked}\n")
|
||||||
@@ -344,13 +377,13 @@ func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
|
|||||||
_ = file.Close()
|
_ = file.Close()
|
||||||
t.Skip("filesystem does not enforce the unreadable-file fixture")
|
t.Skip("filesystem does not enforce the unreadable-file fixture")
|
||||||
}
|
}
|
||||||
mainPath = writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: locked.yml, optional: true}]}\n")
|
mainPath = writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: locked.yml, optional: true}]}\n")
|
||||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeSourcePermission)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeSourcePermission)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
|
func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
|
||||||
writeImportFile(t, directory, "one.yml", "settings: {first: one}\n")
|
writeImportFile(t, directory, "one.yml", "settings: {first: one}\n")
|
||||||
writeImportFile(t, directory, "two.yml", "settings: {second: two}\n")
|
writeImportFile(t, directory, "two.yml", "settings: {second: two}\n")
|
||||||
main := parseImportMain(t, mainPath)
|
main := parseImportMain(t, mainPath)
|
||||||
@@ -370,7 +403,7 @@ func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
writeImportFile(t, directory, "one.yml", "_configkit: {imports: [{path: nested.yml}]}\n")
|
writeImportFile(t, directory, "one.yml", "configkit: {imports: [{path: nested.yml}]}\n")
|
||||||
writeImportFile(t, directory, "nested.yml", "settings: {nested: nested}\n")
|
writeImportFile(t, directory, "nested.yml", "settings: {nested: nested}\n")
|
||||||
main = parseImportMain(t, mainPath)
|
main = parseImportMain(t, mainPath)
|
||||||
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, importLimits{depth: 1, sources: 8, bytes: maxTotalBytes, nodes: maxDocumentNodes})
|
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, importLimits{depth: 1, sources: 8, bytes: maxTotalBytes, nodes: maxDocumentNodes})
|
||||||
@@ -381,7 +414,7 @@ func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
|
|||||||
|
|
||||||
func TestImportTraversalAcceptsExactPrivateBudgets(t *testing.T) {
|
func TestImportTraversalAcceptsExactPrivateBudgets(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: imported.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: imported.yml}]}\n")
|
||||||
importedPath := writeImportFile(t, directory, "imported.yml", "settings: {first: imported}\n")
|
importedPath := writeImportFile(t, directory, "imported.yml", "settings: {first: imported}\n")
|
||||||
main := parseImportMain(t, mainPath)
|
main := parseImportMain(t, mainPath)
|
||||||
imported := parseImportMain(t, importedPath)
|
imported := parseImportMain(t, importedPath)
|
||||||
@@ -434,7 +467,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)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -38,7 +38,7 @@ func TestParallelLoadsShareRulesButNotMutableValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const count = 32
|
const count = 32
|
||||||
results := make([]*Result[independentConfig], count)
|
results := make([]*testResult[independentConfig], count)
|
||||||
errorsByIndex := make([]error, count)
|
errorsByIndex := make([]error, count)
|
||||||
var group sync.WaitGroup
|
var group sync.WaitGroup
|
||||||
for index := range count {
|
for index := range count {
|
||||||
@@ -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"
|
||||||
@@ -73,6 +73,29 @@ func TestParallelLoadsShareRulesButNotMutableValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadedConfigurationIsSeparateFromInspection(t *testing.T) {
|
||||||
|
type configuration struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := Load[configuration](Bytes("config.yml", []byte("value: original\n")), testSecretBinding())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if loaded.Inspection == nil {
|
||||||
|
t.Fatal("inspection is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded.Config.Value = "mutated"
|
||||||
|
var effective bytes.Buffer
|
||||||
|
if err := loaded.Inspection.WriteEffective(&effective); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if effective.String() != "value: original\n" {
|
||||||
|
t.Fatalf("inspection changed with configuration: %q", effective.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConcurrentLoadsOfCopiedReaderSourceHaveOneClaim(t *testing.T) {
|
func TestConcurrentLoadsOfCopiedReaderSourceHaveOneClaim(t *testing.T) {
|
||||||
source := Reader("shared", strings.NewReader("value: one\n"))
|
source := Reader("shared", strings.NewReader("value: one\n"))
|
||||||
copied := source
|
copied := source
|
||||||
@@ -88,7 +111,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)
|
||||||
}
|
}
|
||||||
190
internal/engine/load.go
Normal file
190
internal/engine/load.go
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Definition contains reusable application rules for a configuration type.
|
||||||
|
// Secret is a pure predicate that can add sensitivity to semantic paths.
|
||||||
|
// Definitions should be treated as read-only after being shared.
|
||||||
|
type Definition[T any] struct {
|
||||||
|
Defaults func() T
|
||||||
|
Resolve func(*T, *Resolution) error
|
||||||
|
Validate func(*T, *Validation)
|
||||||
|
Version *VersionRule
|
||||||
|
Secret func(Path) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options contains the application definition and per-load input selections.
|
||||||
|
// Imports enables explicit path and glob imports; Overlay selects one fixed overlay;
|
||||||
|
// Overrides contains only values explicitly supplied by an application CLI.
|
||||||
|
type Options[T any] struct {
|
||||||
|
Definition Definition[T]
|
||||||
|
Imports bool
|
||||||
|
Overlay string
|
||||||
|
Filesystem *FilesystemContext
|
||||||
|
Overrides []Override
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilesystemContext supplies filesystem roots for imports and overlays.
|
||||||
|
type FilesystemContext struct {
|
||||||
|
BaseDir string
|
||||||
|
Root string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override describes one explicitly supplied application override. Value uses
|
||||||
|
// parser-neutral built-in scalars, nil, []any, or map[string]any; Flag is a safe
|
||||||
|
// display name and must not contain a raw argument or value.
|
||||||
|
type Override struct {
|
||||||
|
Path Path
|
||||||
|
Value any
|
||||||
|
Flag string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolution exposes entry-state input provenance, surviving-input presence, and
|
||||||
|
// audited application resolution changes. A resolver must not retain or
|
||||||
|
// concurrently use the context.
|
||||||
|
type Resolution struct {
|
||||||
|
state *resolutionState
|
||||||
|
}
|
||||||
|
|
||||||
|
// VersionRule declares the input path and application checker for a schema version.
|
||||||
|
// At that path, string and decimal-integer input is supplied as Version.Text.
|
||||||
|
type VersionRule struct {
|
||||||
|
Path Path
|
||||||
|
Check func(Version, *Validation) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version preserves the absent, null, and textual states of a schema-version input.
|
||||||
|
// Text is the exact spelling of accepted string or decimal-integer input.
|
||||||
|
type Version struct {
|
||||||
|
Present bool
|
||||||
|
Null bool
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loaded is a short-lived generic carrier for a successful configuration load.
|
||||||
|
// Inspection is frozen independently of Config and does not retain application values.
|
||||||
|
type Loaded[T any] struct {
|
||||||
|
Config T
|
||||||
|
Inspection *Inspection
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inspection owns frozen load snapshots and detached query state.
|
||||||
|
type Inspection struct {
|
||||||
|
metadata Metadata
|
||||||
|
sources []SourceInfo
|
||||||
|
diagnostics []Diagnostic
|
||||||
|
baseline *document.Node
|
||||||
|
input *document.Node
|
||||||
|
inputOrigins map[string]Origin
|
||||||
|
final *document.Node
|
||||||
|
resolution []resolutionSnapshot
|
||||||
|
applications []historyApplication
|
||||||
|
secrets map[string]bool
|
||||||
|
secretReferences map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata returns detached effective presence, origin, and history records.
|
||||||
|
func (r *Inspection) Metadata() Metadata {
|
||||||
|
if r == nil {
|
||||||
|
return Metadata{}
|
||||||
|
}
|
||||||
|
return cloneMetadata(r.metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sources returns a detached list of sources used by the load.
|
||||||
|
func (r *Inspection) Sources() []SourceInfo {
|
||||||
|
if r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cloneSourceInfos(r.sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagnostics returns detached nonfatal diagnostics from the load.
|
||||||
|
func (r *Inspection) Diagnostics() []Diagnostic {
|
||||||
|
if r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cloneDiagnostics(r.diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load parses and strictly decodes zero or one selected source, explicitly enabled
|
||||||
|
// imports, and one selected overlay. It accepts at most one Options value and
|
||||||
|
// returns no result when any error is reported.
|
||||||
|
func Load[T any](source Source, secrets SecretBinding, supplied ...Options[T]) (*Loaded[T], error) {
|
||||||
|
return loadWithSecretLookup(source, secrets, os.LookupEnv, supplied...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup secretLookup, supplied ...Options[T]) (*Loaded[T], error) {
|
||||||
|
accumulator := diagnosticAccumulator{}
|
||||||
|
options, diagnostics := selectOptions(source, supplied)
|
||||||
|
accumulator.add(diagnostics...)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, accumulator.loadError()
|
||||||
|
}
|
||||||
|
|
||||||
|
input, diagnostics := assembleBaseInput(source, options)
|
||||||
|
accumulator.sources = input.sources
|
||||||
|
accumulator.add(diagnostics...)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, accumulator.loadError()
|
||||||
|
}
|
||||||
|
schema, schemaIssue := buildSchema(reflect.TypeFor[T](), secrets)
|
||||||
|
if schemaIssue != nil {
|
||||||
|
accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
|
||||||
|
return nil, accumulator.loadError()
|
||||||
|
}
|
||||||
|
versionDiagnostics := checkVersion(options.Definition.Version, input.document)
|
||||||
|
accumulator.add(versionDiagnostics...)
|
||||||
|
if hasErrors(versionDiagnostics) {
|
||||||
|
return nil, accumulator.loadError()
|
||||||
|
}
|
||||||
|
input, diagnostics = applyInputLayers(source, options, input)
|
||||||
|
accumulator.sources = input.sources
|
||||||
|
accumulator.add(diagnostics...)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, accumulator.loadError()
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, diagnostics := decodeLoadInput(schema, options.Definition, input)
|
||||||
|
accumulator.add(diagnostics...)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, accumulator.loadError()
|
||||||
|
}
|
||||||
|
resolved, diagnostics := resolveLoadInput(schema, options.Definition, input, decoded)
|
||||||
|
accumulator.add(diagnostics...)
|
||||||
|
if hasErrors(diagnostics) {
|
||||||
|
return nil, accumulator.loadError()
|
||||||
|
}
|
||||||
|
if !historyNodesWithinLimit(decoded.baseline, decoded.final, resolved.final, resolved.applications, resolved.snapshots) {
|
||||||
|
accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"})
|
||||||
|
return nil, accumulator.loadError()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detach resolver-borrowed values before reference mutation or validation.
|
||||||
|
configuration, ownershipIssue := detachLoadConfiguration(schema, resolved.configuration, cloneUnresolvedSecrets)
|
||||||
|
if ownershipIssue != nil {
|
||||||
|
accumulator.add(*ownershipIssue)
|
||||||
|
return nil, accumulator.loadError()
|
||||||
|
}
|
||||||
|
secretResolution := resolveSecretReferences(reflect.ValueOf(&configuration).Elem(), schema, input.document, lookup)
|
||||||
|
accumulator.add(secretResolution.diagnostics...)
|
||||||
|
if hasErrors(secretResolution.diagnostics) {
|
||||||
|
return nil, accumulator.loadError()
|
||||||
|
}
|
||||||
|
if options.Definition.Validate != nil {
|
||||||
|
diagnostics = runValidation(&configuration, options.Definition.Validate, schema, resolved.final, input.document, secretResolution.lookups)
|
||||||
|
accumulator.add(diagnostics...)
|
||||||
|
if hasErrors(diagnostics) {
|
||||||
|
return nil, accumulator.loadError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolved.configuration = configuration
|
||||||
|
// Publication detaches equal-valued validator replacements, preserving the
|
||||||
|
// validated secret state, then finalizes metadata and sensitivity.
|
||||||
|
return publishLoadResult(schema, options.Definition, resolved, secretResolution.references, &accumulator)
|
||||||
|
}
|
||||||
88
internal/engine/load_input.go
Normal file
88
internal/engine/load_input.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
|
||||||
|
// assembledInput is the source/document handoff across schema and version checks.
|
||||||
|
type assembledInput struct {
|
||||||
|
parsed parsedSource
|
||||||
|
document *document.Node
|
||||||
|
composition *composedDocument
|
||||||
|
sources []SourceInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput, []Diagnostic) {
|
||||||
|
parsed, diagnostics := parseSource(source, "source-1", defaultParseLimits())
|
||||||
|
input := assembledInput{parsed: parsed, document: parsed.document}
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
input.sources = sourceInfos(parsed.source)
|
||||||
|
return input, diagnostics
|
||||||
|
}
|
||||||
|
if options.Imports {
|
||||||
|
input.composition, input.sources, diagnostics = loadImportedDocuments(source, options.Filesystem, parsed, options.Definition.Version, defaultImportLimits())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return input, diagnostics
|
||||||
|
}
|
||||||
|
input.document = input.composition.root
|
||||||
|
} else {
|
||||||
|
input.sources = sourceInfos(parsed.source)
|
||||||
|
if options.Filesystem != nil && !activeOverlay(options.Overlay) {
|
||||||
|
if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil {
|
||||||
|
return input, []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !options.Imports && input.document != nil {
|
||||||
|
if diagnostics := disabledDirectiveDiagnostics(input.document, Path{}); len(diagnostics) != 0 {
|
||||||
|
return input, diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return input, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyInputLayers[T any](source Source, options Options[T], input assembledInput) (assembledInput, []Diagnostic) {
|
||||||
|
if !activeOverlay(options.Overlay) && len(options.Overrides) == 0 {
|
||||||
|
return input, nil
|
||||||
|
}
|
||||||
|
composition, diagnostics := compositionForInput(input)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return input, diagnostics
|
||||||
|
}
|
||||||
|
if activeOverlay(options.Overlay) {
|
||||||
|
composition, input.sources, diagnostics = loadSelectedOverlayDocument(
|
||||||
|
source, options.Filesystem, input.parsed, composition, input.sources,
|
||||||
|
options.Overlay, options.Definition.Version, defaultImportLimits(),
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return input, diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(options.Overrides) != 0 {
|
||||||
|
composition, input.sources, diagnostics = applyOverrides(
|
||||||
|
composition, input.sources, options.Overrides, options.Definition.Version, defaultImportLimits(),
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return input, diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.composition = composition
|
||||||
|
input.document = composition.root
|
||||||
|
return input, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// compositionForInput initializes one handoff for overlays and CLI, including
|
||||||
|
// defaults-only CLI input. Raw source bytes and mapping keys retain their budgets.
|
||||||
|
func compositionForInput(input assembledInput) (*composedDocument, []Diagnostic) {
|
||||||
|
if input.composition != nil {
|
||||||
|
return input.composition, nil
|
||||||
|
}
|
||||||
|
if input.document == nil {
|
||||||
|
return &composedDocument{root: &document.Node{Kind: document.Mapping}, origins: make(map[string]Origin)}, nil
|
||||||
|
}
|
||||||
|
composition, diagnostics := composeDocuments(input.document)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, diagnostics
|
||||||
|
}
|
||||||
|
composition.inputBytes = input.parsed.bytes
|
||||||
|
composition.inputNodes = countInputNodes(input.document)
|
||||||
|
return composition, nil
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"slices"
|
"slices"
|
||||||
@@ -6,6 +6,47 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// resolutionInput is the immutable entry-state provenance available to a
|
||||||
|
// resolution callback. It owns detached metadata and source records so later
|
||||||
|
// resolution updates cannot change the entry view.
|
||||||
|
type resolutionInput struct {
|
||||||
|
metadata Metadata
|
||||||
|
sources map[string]SourceInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func newResolutionInput(metadata Metadata, final *document.Node, sources []SourceInfo) resolutionInput {
|
||||||
|
frozen := Metadata{
|
||||||
|
fields: make(map[string]FieldInfo, len(metadata.fields)),
|
||||||
|
effectivePaths: effectivePathIndex(final),
|
||||||
|
}
|
||||||
|
for pointer, info := range metadata.fields {
|
||||||
|
frozen.fields[pointer] = cloneFieldInfo(info)
|
||||||
|
}
|
||||||
|
indexed := make(map[string]SourceInfo, len(sources))
|
||||||
|
for _, source := range sources {
|
||||||
|
if source.ID == "" {
|
||||||
|
panic("configkit: source identity is missing")
|
||||||
|
}
|
||||||
|
if _, exists := indexed[source.ID]; exists {
|
||||||
|
panic("configkit: source identity is duplicated")
|
||||||
|
}
|
||||||
|
indexed[source.ID] = cloneSourceInfo(source)
|
||||||
|
}
|
||||||
|
return resolutionInput{metadata: frozen, sources: indexed}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (input resolutionInput) lookup(path Path) (FieldInfo, bool) {
|
||||||
|
return input.metadata.Lookup(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (input resolutionInput) source(id string) (SourceInfo, bool) {
|
||||||
|
source, ok := input.sources[id]
|
||||||
|
if !ok {
|
||||||
|
return SourceInfo{}, false
|
||||||
|
}
|
||||||
|
return cloneSourceInfo(source), true
|
||||||
|
}
|
||||||
|
|
||||||
func metadataFromDecoded(decoded decodedConfiguration) Metadata {
|
func metadataFromDecoded(decoded decodedConfiguration) Metadata {
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
fields: make(map[string]FieldInfo),
|
fields: make(map[string]FieldInfo),
|
||||||
@@ -159,23 +200,6 @@ func collectFieldInfo(node *document.Node, path Path, decoded decodedConfigurati
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func indexDocument(node *document.Node, path Path, index map[string]*document.Node) {
|
|
||||||
if node == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
index[path.String()] = node
|
|
||||||
switch node.Kind {
|
|
||||||
case document.Mapping:
|
|
||||||
for _, entry := range node.Mapping {
|
|
||||||
indexDocument(entry.Value, path.Key(entry.Key), index)
|
|
||||||
}
|
|
||||||
case document.Sequence:
|
|
||||||
for position, child := range node.Sequence {
|
|
||||||
indexDocument(child, path.Index(position), index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneMetadata(metadata Metadata) Metadata {
|
func cloneMetadata(metadata Metadata) Metadata {
|
||||||
if metadata.fields == nil && metadata.history == nil {
|
if metadata.fields == nil && metadata.history == nil {
|
||||||
return Metadata{}
|
return Metadata{}
|
||||||
120
internal/engine/load_result.go
Normal file
120
internal/engine/load_result.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resolvedLoad carries typed phase outputs needed for validation and publication;
|
||||||
|
// it owns no source access, options, or long-lived schema state.
|
||||||
|
type resolvedLoad[T any] struct {
|
||||||
|
configuration T
|
||||||
|
decoded decodedConfiguration
|
||||||
|
metadata Metadata
|
||||||
|
final *document.Node
|
||||||
|
snapshots []resolutionSnapshot
|
||||||
|
applications []historyApplication
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeLoadInput[T any](schema *schemaDescriptor, definition Definition[T], input assembledInput) (decodedConfiguration, []Diagnostic) {
|
||||||
|
defaults := reflect.Zero(reflect.TypeFor[T]())
|
||||||
|
if definition.Defaults != nil {
|
||||||
|
defaults = reflect.ValueOf(definition.Defaults())
|
||||||
|
}
|
||||||
|
context := decodeContext{}
|
||||||
|
if definition.Version != nil {
|
||||||
|
context.versionPath = &definition.Version.Path
|
||||||
|
}
|
||||||
|
decoded, decodeIssues := decodeConfigurationWithContext(schema, defaults, input.document, context)
|
||||||
|
if len(decodeIssues) != 0 {
|
||||||
|
diagnostics := make([]Diagnostic, len(decodeIssues))
|
||||||
|
for i, issue := range decodeIssues {
|
||||||
|
diagnostics[i] = diagnosticFromDecodeIssue(issue)
|
||||||
|
}
|
||||||
|
return decodedConfiguration{}, diagnostics
|
||||||
|
}
|
||||||
|
if input.composition != nil {
|
||||||
|
applyCompositionOrigins(&decoded, input.composition.origins)
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T], input assembledInput, decoded decodedConfiguration) (resolvedLoad[T], []Diagnostic) {
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
configuration := decoded.value.Interface().(T)
|
||||||
|
metadata := metadataFromDecoded(decoded)
|
||||||
|
if input.composition != nil {
|
||||||
|
applyOverlayMetadata(&metadata, input.composition.overlays)
|
||||||
|
}
|
||||||
|
final := decoded.final
|
||||||
|
var resolutionSnapshots []resolutionSnapshot
|
||||||
|
if definition.Resolve != nil {
|
||||||
|
entry := newResolutionInput(metadata, decoded.final, input.sources)
|
||||||
|
resolved := runResolution(&configuration, definition.Resolve, schema, decoded, input.document, entry)
|
||||||
|
diagnostics = append(diagnostics, resolved.diagnostics...)
|
||||||
|
if hasErrors(resolved.diagnostics) {
|
||||||
|
return resolvedLoad[T]{}, diagnostics
|
||||||
|
}
|
||||||
|
final = resolved.final
|
||||||
|
resolutionSnapshots = resolved.snapshots
|
||||||
|
applyResolutionMetadata(&metadata, resolved)
|
||||||
|
}
|
||||||
|
applications := []historyApplication(nil)
|
||||||
|
if input.composition != nil {
|
||||||
|
applications = cloneHistoryApplications(input.composition.applications)
|
||||||
|
} else if input.document != nil {
|
||||||
|
applications = []historyApplication{{layer: LayerBase, value: input.document}}
|
||||||
|
}
|
||||||
|
return resolvedLoad[T]{configuration: configuration, decoded: decoded, metadata: metadata, final: final, snapshots: resolutionSnapshots, applications: applications}, diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
func detachLoadConfiguration[T any](schema *schemaDescriptor, configuration T, mode secretCloneMode) (T, *Diagnostic) {
|
||||||
|
detached, issue := cloneConfigurationWithMode(schema, reflect.ValueOf(configuration), defaultProjectionLimits(), mode)
|
||||||
|
if issue != nil {
|
||||||
|
diagnostic := diagnosticFromDecodeIssue(decodeIssueFromProjection(issue))
|
||||||
|
var zero T
|
||||||
|
return zero, &diagnostic
|
||||||
|
}
|
||||||
|
return detached.Interface().(T), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T], resolved resolvedLoad[T], references map[string]string, accumulator *diagnosticAccumulator) (*Loaded[T], error) {
|
||||||
|
// A read-only validator can replace a value with an equal borrowed object.
|
||||||
|
// Detach without re-reading the environment or discarding validated state.
|
||||||
|
configuration, issue := detachLoadConfiguration(schema, resolved.configuration, cloneValidatedSecrets)
|
||||||
|
if issue != nil {
|
||||||
|
accumulator.add(*issue)
|
||||||
|
return nil, accumulator.loadError()
|
||||||
|
}
|
||||||
|
decoded, metadata := resolved.decoded, resolved.metadata
|
||||||
|
final, applications, resolutionSnapshots := resolved.final, resolved.applications, resolved.snapshots
|
||||||
|
finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final)
|
||||||
|
secrets := classifySecretPaths(schema, definition.Secret, metadata, final, applications, decoded.baseline, decoded.final)
|
||||||
|
inspection := &Inspection{
|
||||||
|
metadata: metadata,
|
||||||
|
sources: cloneSourceInfos(accumulator.sources),
|
||||||
|
diagnostics: canonicalDiagnostics(accumulator.diagnostics),
|
||||||
|
baseline: decoded.baseline,
|
||||||
|
input: decoded.final,
|
||||||
|
inputOrigins: cloneOriginIndex(decoded.origins),
|
||||||
|
final: final,
|
||||||
|
resolution: resolutionSnapshots,
|
||||||
|
applications: applications,
|
||||||
|
secrets: secrets,
|
||||||
|
secretReferences: cloneSecretReferences(references),
|
||||||
|
}
|
||||||
|
return &Loaded[T]{Config: configuration, Inspection: inspection}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func diagnosticFromSchemaIssue(issue *schemaIssue) Diagnostic {
|
||||||
|
return Diagnostic{Code: issue.code, Severity: SeverityError, Path: issue.path.clone(), Message: issue.message}
|
||||||
|
}
|
||||||
|
|
||||||
|
func diagnosticFromDecodeIssue(issue decodeIssue) Diagnostic {
|
||||||
|
return Diagnostic{
|
||||||
|
Code: issue.code, Severity: SeverityError, Path: issue.path.clone(), Message: issue.message,
|
||||||
|
Primary: documentLocationPointer(issue.location),
|
||||||
|
}
|
||||||
|
}
|
||||||
604
internal/engine/load_test.go
Normal file
604
internal/engine/load_test.go
Normal file
@@ -0,0 +1,604 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadSingleSourceKindsAndDefaultsOnly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
}
|
||||||
|
input := []byte("name: configured\n")
|
||||||
|
temporary := t.TempDir()
|
||||||
|
filename := filepath.Join(temporary, "config.yml")
|
||||||
|
if err := os.WriteFile(filename, input, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
source func() Source
|
||||||
|
want string
|
||||||
|
wantSources int
|
||||||
|
}{
|
||||||
|
{name: "file", source: func() Source { return File(filename) }, want: "configured", wantSources: 1},
|
||||||
|
{name: "bytes", source: func() Source { return Bytes("memory.yml", input) }, want: "configured", wantSources: 1},
|
||||||
|
{name: "reader", source: func() Source { return Reader("reader.yml", strings.NewReader(string(input))) }, want: "configured", wantSources: 1},
|
||||||
|
{name: "no source", source: NoSource, want: "default", wantSources: 0},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
var defaultsCalls atomic.Int32
|
||||||
|
result, err := testLoad(test.source(), Options[config]{Definition: Definition[config]{
|
||||||
|
Defaults: func() config {
|
||||||
|
defaultsCalls.Add(1)
|
||||||
|
return config{Name: "default"}
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config.Name != test.want || defaultsCalls.Load() != 1 || len(result.Sources()) != test.wantSources {
|
||||||
|
t.Fatalf("result = %#v, calls = %d, sources = %#v", result.Config, defaultsCalls.Load(), result.Sources())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadVersionStatesWarningsAndSafeFailures(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
SchemaVersion *string `yaml:"schema_version"`
|
||||||
|
}
|
||||||
|
versionPath := Keys("schema_version")
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
source Source
|
||||||
|
want Version
|
||||||
|
wantLine int
|
||||||
|
wantErr Code
|
||||||
|
returnOnly bool
|
||||||
|
}{
|
||||||
|
{name: "absent", source: Bytes("absent.yml", []byte("{}\n")), want: Version{}, wantLine: 1},
|
||||||
|
{name: "no source absent", source: NoSource(), want: Version{}},
|
||||||
|
{name: "null", source: Bytes("null.yml", []byte("schema_version: null\n")), want: Version{Present: true, Null: true}, wantLine: 1},
|
||||||
|
{name: "string", source: Bytes("string.yml", []byte("schema_version: app.v1\n")), want: Version{Present: true, Text: "app.v1"}, wantLine: 1},
|
||||||
|
{name: "integer", source: Bytes("integer.yml", []byte("schema_version: 1\n")), want: Version{Present: true, Text: "1"}, wantLine: 1},
|
||||||
|
{name: "boolean", source: Bytes("boolean.yml", []byte("schema_version: true\n")), wantErr: CodeVersionType},
|
||||||
|
{name: "sequence", source: Bytes("sequence.yml", []byte("schema_version: []\n")), wantErr: CodeVersionType},
|
||||||
|
{name: "returned error", source: Bytes("return.yml", []byte("{}\n")), wantErr: CodeVersionHookFailed, returnOnly: true},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
rule := &VersionRule{Path: versionPath, Check: func(version Version, validation *Validation) error {
|
||||||
|
called = true
|
||||||
|
if test.returnOnly {
|
||||||
|
return errors.New("CANARY_VERSION_ERROR")
|
||||||
|
}
|
||||||
|
if version != test.want {
|
||||||
|
t.Errorf("version = %#v, want %#v", version, test.want)
|
||||||
|
}
|
||||||
|
validation.Warning(versionPath, "review schema compatibility", Keys("missing_related"))
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
result, err := testLoad(test.source, Options[config]{Definition: Definition[config]{Version: rule}})
|
||||||
|
if test.wantErr != "" {
|
||||||
|
if result != nil || err == nil {
|
||||||
|
t.Fatalf("result, err = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("error type = %T", err)
|
||||||
|
}
|
||||||
|
diagnostics := loadError.Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != test.wantErr || strings.Contains(diagnostics[0].Message, "CANARY") {
|
||||||
|
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if test.wantErr == CodeVersionType && called {
|
||||||
|
t.Fatal("checker ran for an unsupported version kind")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
diagnostics := result.Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeVersionWarning || diagnostics[0].Severity != SeverityWarning {
|
||||||
|
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if test.wantLine == 0 && (diagnostics[0].Primary != nil || len(diagnostics[0].Related) != 0) {
|
||||||
|
t.Fatalf("synthetic diagnostic locations = %#v", diagnostics[0])
|
||||||
|
}
|
||||||
|
if test.wantLine != 0 && (diagnostics[0].Primary == nil || diagnostics[0].Primary.Line != test.wantLine || len(diagnostics[0].Related) != 1) {
|
||||||
|
t.Fatalf("diagnostic locations = %#v", diagnostics[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDecodesIntegerVersionOnlyAtConfiguredStringPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type stringConfig struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
}
|
||||||
|
type pointerConfig struct {
|
||||||
|
Version *string `yaml:"version"`
|
||||||
|
}
|
||||||
|
versionPath := Keys("version")
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
source string
|
||||||
|
load func(Source, *VersionRule) (string, error)
|
||||||
|
}{
|
||||||
|
{name: "integer into string", source: "version: 4\n", load: func(source Source, rule *VersionRule) (string, error) {
|
||||||
|
result, err := testLoad(source, Options[stringConfig]{Definition: Definition[stringConfig]{Version: rule}})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return result.Config.Version, nil
|
||||||
|
}},
|
||||||
|
{name: "string into string", source: "version: \"4\"\n", load: func(source Source, rule *VersionRule) (string, error) {
|
||||||
|
result, err := testLoad(source, Options[stringConfig]{Definition: Definition[stringConfig]{Version: rule}})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return result.Config.Version, nil
|
||||||
|
}},
|
||||||
|
{name: "integer into pointer", source: "version: 4\n", load: func(source Source, rule *VersionRule) (string, error) {
|
||||||
|
result, err := testLoad(source, Options[pointerConfig]{Definition: Definition[pointerConfig]{Version: rule}})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if result.Config.Version == nil {
|
||||||
|
t.Fatal("version pointer is nil")
|
||||||
|
}
|
||||||
|
return *result.Config.Version, nil
|
||||||
|
}},
|
||||||
|
{name: "string into pointer", source: "version: \"4\"\n", load: func(source Source, rule *VersionRule) (string, error) {
|
||||||
|
result, err := testLoad(source, Options[pointerConfig]{Definition: Definition[pointerConfig]{Version: rule}})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if result.Config.Version == nil {
|
||||||
|
t.Fatal("version pointer is nil")
|
||||||
|
}
|
||||||
|
return *result.Config.Version, nil
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
var checked Version
|
||||||
|
rule := &VersionRule{Path: versionPath, Check: func(version Version, _ *Validation) error {
|
||||||
|
checked = version
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
got, err := test.load(Bytes("version.yml", []byte(test.source)), rule)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "4" || checked != (Version{Present: true, Text: "4"}) {
|
||||||
|
t.Fatalf("value = %q, checked = %#v", got, checked)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if result, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[stringConfig]{}); result != nil {
|
||||||
|
t.Fatalf("load without rule = %#v, %v", result, err)
|
||||||
|
} else {
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeDecodeType {
|
||||||
|
t.Fatalf("load without rule error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type unrelatedConfig struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
Label string `yaml:"label"`
|
||||||
|
}
|
||||||
|
rule := &VersionRule{Path: versionPath, Check: func(Version, *Validation) error { return 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)
|
||||||
|
} else {
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeDecodeType || loadError.Diagnostics()[0].Path.String() != "/label" {
|
||||||
|
t.Fatalf("unrelated coercion error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadVersionScalarAndPathBoundaries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "zero", value: "0", want: "0"},
|
||||||
|
{name: "negative", value: "-7", want: "-7"},
|
||||||
|
{name: "negative zero", value: "-0", want: "-0"},
|
||||||
|
{name: "beyond int64", value: "123456789012345678901234567890", want: "123456789012345678901234567890"},
|
||||||
|
{name: "empty string", value: `""`, want: ""},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
var checked Version
|
||||||
|
rule := &VersionRule{Path: Keys("version"), Check: func(version Version, _ *Validation) error {
|
||||||
|
checked = version
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
result, err := testLoad(Bytes("version.yml", []byte("version: "+test.value+"\n")), Options[config]{Definition: Definition[config]{Version: rule}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config.Version != test.want || checked.Text != test.want || !checked.Present || checked.Null {
|
||||||
|
t.Fatalf("configuration = %#v, checked = %#v", result.Config, checked)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
}{
|
||||||
|
{name: "fraction", value: "4.0"},
|
||||||
|
{name: "exponent", value: "4e0"},
|
||||||
|
{name: "boolean", value: "true"},
|
||||||
|
{name: "mapping", value: "{}"},
|
||||||
|
{name: "sequence", value: "[]"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
rule := &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error {
|
||||||
|
called = true
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
result, err := testLoad(Bytes("version.yml", []byte("version: "+test.value+"\n")), Options[config]{Definition: Definition[config]{Version: rule}})
|
||||||
|
if result != nil || called {
|
||||||
|
t.Fatalf("result = %#v, checker called = %v", result, called)
|
||||||
|
}
|
||||||
|
requireDiagnostic(t, err, CodeVersionType, "/version")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type namedVersion string
|
||||||
|
type nested struct {
|
||||||
|
Versions map[string]**namedVersion `yaml:"versions"`
|
||||||
|
Sibling namedVersion `yaml:"sibling"`
|
||||||
|
}
|
||||||
|
parsed, err := ParsePath("/versions/0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rule := &VersionRule{Path: parsed, Check: func(version Version, _ *Validation) error {
|
||||||
|
if version.Text != "4" {
|
||||||
|
t.Errorf("version = %#v", version)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
result, err := testLoad(Bytes("nested.yml", []byte("versions:\n \"0\": 4\nsibling: quoted\n")), Options[nested]{Definition: Definition[nested]{Version: rule}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config.Versions["0"] == nil || *result.Config.Versions["0"] == nil || **result.Config.Versions["0"] != "4" || result.Config.Sibling != "quoted" {
|
||||||
|
t.Fatalf("nested configuration = %#v", result.Config)
|
||||||
|
}
|
||||||
|
type sequenceConfig struct {
|
||||||
|
Versions []namedVersion `yaml:"versions"`
|
||||||
|
}
|
||||||
|
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" {
|
||||||
|
t.Fatalf("sequence configuration = %#v, %v", sequenceResult, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type escaped struct {
|
||||||
|
Versions map[string]namedVersion `yaml:"versions"`
|
||||||
|
}
|
||||||
|
escapedPath, err := ParsePath("/versions/a~1b")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
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" {
|
||||||
|
t.Fatalf("escaped configuration = %#v, %v", escapedResult, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
siblingRule := &VersionRule{Path: Keys("versions", "target"), Check: func(Version, *Validation) error { return 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)
|
||||||
|
} else {
|
||||||
|
requireDiagnostic(t, err, CodeDecodeType, "/versions/other")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadVersionDestinationTypesKeepOrdinaryDecoding(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
check := func(Version, *Validation) error { return nil }
|
||||||
|
t.Run("integer destination", func(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Version int8 `yaml:"version"`
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
t.Fatalf("result = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
t.Fatalf("range result = %#v", result)
|
||||||
|
}
|
||||||
|
requireDiagnostic(t, err, CodeDecodeRange, "/version")
|
||||||
|
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 {
|
||||||
|
t.Fatalf("quoted integer result = %#v", result)
|
||||||
|
}
|
||||||
|
requireDiagnostic(t, err, CodeDecodeType, "/version")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("dynamic destination", func(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Version any `yaml:"version"`
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
t.Fatalf("result = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
t.Fatalf("range result = %#v", result)
|
||||||
|
}
|
||||||
|
requireDiagnostic(t, err, CodeDecodeRange, "/version")
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
load func() error
|
||||||
|
}{
|
||||||
|
{name: "text codec", load: func() error {
|
||||||
|
type config struct {
|
||||||
|
Version validatedText `yaml:"version"`
|
||||||
|
}
|
||||||
|
_, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
{name: "duration", load: func() error {
|
||||||
|
type config struct {
|
||||||
|
Version time.Duration `yaml:"version"`
|
||||||
|
}
|
||||||
|
_, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
{name: "secret", load: func() error {
|
||||||
|
type config struct {
|
||||||
|
Version Secret `yaml:"version"`
|
||||||
|
}
|
||||||
|
_, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
requireDiagnostic(t, test.load(), CodeDecodeType, "/version")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadVersionPolicyStopsLaterHooks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
}
|
||||||
|
var defaultsCalled, resolutionCalled, validationCalled bool
|
||||||
|
rule := &VersionRule{Path: Keys("version"), Check: func(version Version, validation *Validation) error {
|
||||||
|
if version.Text != "4" {
|
||||||
|
t.Errorf("version = %#v", version)
|
||||||
|
}
|
||||||
|
validation.Error(Keys("version"), "unsupported configuration version")
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
result, err := testLoad(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{
|
||||||
|
Defaults: func() config { defaultsCalled = true; return config{} },
|
||||||
|
Version: rule,
|
||||||
|
Resolve: func(*config, *Resolution) error { resolutionCalled = true; return nil },
|
||||||
|
Validate: func(*config, *Validation) { validationCalled = true },
|
||||||
|
}})
|
||||||
|
if result != nil || defaultsCalled || resolutionCalled || validationCalled {
|
||||||
|
t.Fatalf("result = %#v, hooks = %v/%v/%v", result, defaultsCalled, resolutionCalled, validationCalled)
|
||||||
|
}
|
||||||
|
requireDiagnostic(t, err, CodeVersionError, "/version")
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireDiagnostic(t *testing.T, err error, code Code, path string) {
|
||||||
|
t.Helper()
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("error = %T %v", err, err)
|
||||||
|
}
|
||||||
|
diagnostics := loadError.Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != code || diagnostics[0].Path.String() != path || diagnostics[0].Primary == nil {
|
||||||
|
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadVersionReportSuppressesReturnedErrorTextAndDefaults(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
}
|
||||||
|
var defaultsCalls atomic.Int32
|
||||||
|
result, err := testLoad(Bytes("version.yml", []byte("name: value\n")), Options[config]{Definition: Definition[config]{
|
||||||
|
Defaults: func() config {
|
||||||
|
defaultsCalls.Add(1)
|
||||||
|
return config{Name: "default"}
|
||||||
|
},
|
||||||
|
Version: &VersionRule{Path: Keys("schema_version"), Check: func(_ Version, validation *Validation) error {
|
||||||
|
validation.Error(Keys("schema_version"), "schema_version is required")
|
||||||
|
return errors.New("CANARY_RETURNED_ERROR")
|
||||||
|
}},
|
||||||
|
}})
|
||||||
|
if result != nil || err == nil || defaultsCalls.Load() != 0 {
|
||||||
|
t.Fatalf("result = %#v, err = %v, calls = %d", result, err, defaultsCalls.Load())
|
||||||
|
}
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("error type = %T", err)
|
||||||
|
}
|
||||||
|
diagnostics := loadError.Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeVersionError || strings.Contains(diagnostics[0].Message, "CANARY") {
|
||||||
|
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadMetadataReflectsDefaultsInputAndDetachedRecords(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type nested struct {
|
||||||
|
Kept string `yaml:"kept"`
|
||||||
|
Set string `yaml:"set"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Nested nested `yaml:"nested"`
|
||||||
|
Nullable map[string]string `yaml:"nullable"`
|
||||||
|
}
|
||||||
|
result, err := testLoad(Bytes("metadata.yml", []byte("nested: {set: configured}\nnullable: null\n")), Options[config]{Definition: Definition[config]{
|
||||||
|
Defaults: func() config {
|
||||||
|
return config{Nested: nested{Kept: "default", Set: "default"}, Nullable: map[string]string{"key": "value"}}
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
metadata := result.Metadata()
|
||||||
|
kept, ok := metadata.Lookup(Keys("nested", "kept"))
|
||||||
|
if !ok || kept.Explicit || kept.Origin.Layer != LayerDefault {
|
||||||
|
t.Fatalf("kept = %#v, %v", kept, ok)
|
||||||
|
}
|
||||||
|
set, ok := metadata.Lookup(Keys("nested", "set"))
|
||||||
|
if !ok || !set.Explicit || set.Origin.Layer != LayerBase || len(set.Origin.Locations) != 1 {
|
||||||
|
t.Fatalf("set = %#v, %v", set, ok)
|
||||||
|
}
|
||||||
|
nullable, ok := metadata.Lookup(Keys("nullable"))
|
||||||
|
if !ok || !nullable.Explicit || !nullable.Null || nullable.Origin.Layer != LayerBase {
|
||||||
|
t.Fatalf("nullable = %#v, %v", nullable, ok)
|
||||||
|
}
|
||||||
|
if metadata.IsExplicit(Keys("nested", "kept")) || !metadata.IsExplicit(Keys("nested", "set")) {
|
||||||
|
t.Fatal("explicit presence disagrees with lookup")
|
||||||
|
}
|
||||||
|
if history := metadata.History(Keys("nested", "set")); len(history) != 2 || history[0].Origin.Layer != LayerDefault || history[1].Origin.Layer != LayerBase || len(history[1].Previous) != 1 {
|
||||||
|
t.Fatalf("history = %#v", history)
|
||||||
|
}
|
||||||
|
|
||||||
|
set.Origin.Locations[0].Line = 99
|
||||||
|
history := metadata.History(Keys("nested", "set"))
|
||||||
|
history[1].Origin.Locations[0].Line = 99
|
||||||
|
sources := result.Sources()
|
||||||
|
sources[0].Label = "changed"
|
||||||
|
diagnostics := result.Diagnostics()
|
||||||
|
if diagnostics != nil {
|
||||||
|
diagnostics[0].Message = "changed"
|
||||||
|
}
|
||||||
|
fresh, _ := result.Metadata().Lookup(Keys("nested", "set"))
|
||||||
|
if fresh.Origin.Locations[0].Line == 99 || result.Sources()[0].Label != "metadata.yml" || result.Diagnostics() != nil {
|
||||||
|
t.Fatal("result inspection aliases internal records")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
}
|
||||||
|
validSource := func() Source { return Bytes("options.yml", []byte("name: value\n")) }
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
source Source
|
||||||
|
options []Options[config]
|
||||||
|
code Code
|
||||||
|
}{
|
||||||
|
{name: "multiple options", source: validSource(), options: []Options[config]{{}, {}}, code: CodeOptionsInvalid},
|
||||||
|
{name: "imports without filesystem context", source: validSource(), options: []Options[config]{{Imports: true}}, code: CodeOptionsInvalid},
|
||||||
|
{name: "incomplete filesystem", source: validSource(), options: []Options[config]{{Filesystem: &FilesystemContext{}}}, code: CodeOptionsInvalid},
|
||||||
|
{name: "missing version checker", source: validSource(), options: []Options[config]{{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version")}}}}, code: CodeOptionsInvalid},
|
||||||
|
{name: "no-source filesystem", source: NoSource(), options: []Options[config]{{Filesystem: &FilesystemContext{}}}, code: CodeOptionsInvalid},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := testLoad(test.source, test.options...)
|
||||||
|
if result != nil || err == nil {
|
||||||
|
t.Fatalf("result, err = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != test.code {
|
||||||
|
t.Fatalf("error = %#v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := testLoad[config](Bytes("directive.yml", []byte("nested:\n configkit: {}\n")))
|
||||||
|
if result != nil || err == nil {
|
||||||
|
t.Fatalf("result, err = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
|
if diagnostic.Code != CodeImportDisabled || diagnostic.Path.String() != "/nested/configkit" || diagnostic.Primary == nil || diagnostic.Primary.Line != 2 {
|
||||||
|
t.Fatalf("diagnostic = %#v", diagnostic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadReturnsNilForSourceSchemaAndDecodeFailures(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type invalidSchema struct {
|
||||||
|
Channel chan int `yaml:"channel"`
|
||||||
|
}
|
||||||
|
type validConfig struct {
|
||||||
|
Known int `yaml:"known"`
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
run func() (any, error)
|
||||||
|
code Code
|
||||||
|
}{
|
||||||
|
{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 testLoad[invalidSchema](Bytes("schema.yml", []byte("{}\n"))) }, code: CodeSchemaUnsupportedType},
|
||||||
|
{name: "decode", run: func() (any, error) { return testLoad[validConfig](Bytes("decode.yml", []byte("unknown: 1\n"))) }, code: CodeDecodeUnknownField},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := test.run()
|
||||||
|
if !isNilInterface(result) || err == nil {
|
||||||
|
t.Fatalf("result, err = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != test.code {
|
||||||
|
t.Fatalf("error = %#v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNilInterface(value any) bool {
|
||||||
|
if value == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
reflected := reflect.ValueOf(value)
|
||||||
|
return reflected.Kind() == reflect.Pointer && reflected.IsNil()
|
||||||
|
}
|
||||||
149
internal/engine/metadata.go
Normal file
149
internal/engine/metadata.go
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Metadata provides detached effective presence/origin and chronological history
|
||||||
|
// queries for a load. Its zero value contains no paths.
|
||||||
|
type Metadata struct {
|
||||||
|
effectivePaths map[string]Path
|
||||||
|
historicalPaths map[string][]Path
|
||||||
|
fields map[string]FieldInfo
|
||||||
|
history map[string][]Change
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup returns information for an effective path. Unknown and invalid paths
|
||||||
|
// return false.
|
||||||
|
func (m Metadata) Lookup(path Path) (FieldInfo, bool) {
|
||||||
|
if !path.valid() || !matchesPathShape(path, m.effectivePaths[path.String()]) {
|
||||||
|
return FieldInfo{}, false
|
||||||
|
}
|
||||||
|
info, ok := m.fields[path.String()]
|
||||||
|
if !ok {
|
||||||
|
return FieldInfo{}, false
|
||||||
|
}
|
||||||
|
return cloneFieldInfo(info), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsExplicit reports whether a surviving input contribution explicitly defined
|
||||||
|
// path.
|
||||||
|
func (m Metadata) IsExplicit(path Path) bool {
|
||||||
|
info, ok := m.Lookup(path)
|
||||||
|
return ok && info.Explicit
|
||||||
|
}
|
||||||
|
|
||||||
|
// History returns detached definition and resolution records for path. A record's
|
||||||
|
// Path can be an ancestor when that container operation replaced or removed path.
|
||||||
|
// Removed historical paths can have history even though Lookup returns false.
|
||||||
|
// Unknown and invalid paths return nil.
|
||||||
|
func (m Metadata) History(path Path) []Change {
|
||||||
|
if !m.hasHistoricalPath(path) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
history, ok := m.history[path.String()]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := make([]Change, len(history))
|
||||||
|
for i, change := range history {
|
||||||
|
cloned[i] = cloneChange(change)
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
// HistoricalPaths returns the paths observed during loading, including effective
|
||||||
|
// paths and paths removed by overlays or recorded resolution. It includes default
|
||||||
|
// paths and the root, but not paths that never existed. The zero Metadata returns
|
||||||
|
// an empty slice.
|
||||||
|
//
|
||||||
|
// Paths are detached, unique by canonical JSON Pointer, and sorted by that
|
||||||
|
// spelling. Like ParsePath results, numeric tokens are resolved contextually;
|
||||||
|
// one pointer can identify a mapping key in an earlier layer and a sequence index
|
||||||
|
// in a later one. Use History to inspect definitions and Lookup to check whether
|
||||||
|
// a path remains effective. Enumeration exposes no configuration values and does
|
||||||
|
// not perform another load or resolve secrets.
|
||||||
|
func (m Metadata) HistoricalPaths() []Path {
|
||||||
|
pointers := make([]string, 0, len(m.historicalPaths))
|
||||||
|
for pointer := range m.historicalPaths {
|
||||||
|
pointers = append(pointers, pointer)
|
||||||
|
}
|
||||||
|
sort.Strings(pointers)
|
||||||
|
paths := make([]Path, 0, len(pointers))
|
||||||
|
for _, pointer := range pointers {
|
||||||
|
// The index contains only canonical pointers produced by Path.String.
|
||||||
|
path, _ := ParsePath(pointer)
|
||||||
|
paths = append(paths, path)
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
// Textual pointers are lookup keys, but explicit key/index kinds remain part of
|
||||||
|
// the query. Candidate paths are constructed from actual document containers.
|
||||||
|
func matchesPathShape(query, candidate Path) bool {
|
||||||
|
if !query.valid() || query.String() != candidate.String() || len(query.segments) != len(candidate.segments) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, segment := range query.segments {
|
||||||
|
actual := candidate.segments[i]
|
||||||
|
if actual.kind == pathSegmentIndex {
|
||||||
|
index, ok := schemaIndex(segment)
|
||||||
|
if !ok || index != actual.index {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else if segment.kind == pathSegmentIndex {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Metadata) hasHistoricalPath(path Path) bool {
|
||||||
|
if !path.valid() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, candidate := range m.historicalPaths[path.String()] {
|
||||||
|
if matchesPathShape(path, candidate) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []historyApplication, earlier ...*document.Node) {
|
||||||
|
m.effectivePaths = effectivePathIndex(final)
|
||||||
|
m.historicalPaths = make(map[string][]Path)
|
||||||
|
add := func(path Path, _ *document.Node) {
|
||||||
|
pointer := path.String()
|
||||||
|
for _, prior := range m.historicalPaths[pointer] {
|
||||||
|
if matchesPathShape(path, prior) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.historicalPaths[pointer] = append(m.historicalPaths[pointer], path.clone())
|
||||||
|
}
|
||||||
|
walkDocument(final, Path{}, add)
|
||||||
|
for _, tree := range earlier {
|
||||||
|
walkDocument(tree, Path{}, add)
|
||||||
|
}
|
||||||
|
for _, application := range applications {
|
||||||
|
// Overrides have mapping-only prefixes; parsed tokens are keys in this layer.
|
||||||
|
prefix := Path{}
|
||||||
|
for _, segment := range application.path.segments {
|
||||||
|
prefix = prefix.Key(segment.key)
|
||||||
|
}
|
||||||
|
walkDocument(application.value, prefix, add)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// effectivePathIndex records document-derived paths with their contextual segment
|
||||||
|
// kinds. Callers retain no aliases to the walked tree or its paths.
|
||||||
|
func effectivePathIndex(final *document.Node) map[string]Path {
|
||||||
|
paths := make(map[string]Path)
|
||||||
|
walkDocument(final, Path{}, func(path Path, _ *document.Node) {
|
||||||
|
paths[path.String()] = path.clone()
|
||||||
|
})
|
||||||
|
return paths
|
||||||
|
}
|
||||||
274
internal/engine/metadata_test.go
Normal file
274
internal/engine/metadata_test.go
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMetadataHistoricalPaths(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
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{
|
||||||
|
{Path: Keys("retired"), Value: nil, Flag: "--retired"},
|
||||||
|
{Path: Keys("shape"), Value: []any{"replacement"}, Flag: "--shape"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
metadata := result.Metadata()
|
||||||
|
paths := metadata.HistoricalPaths()
|
||||||
|
var got []string
|
||||||
|
for _, path := range paths {
|
||||||
|
got = append(got, path.String())
|
||||||
|
if len(metadata.History(path)) == 0 {
|
||||||
|
t.Fatalf("enumerated path %s has no history", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want := []string{"", "/a~1b~0", "/a~1b~0/", "/retired", "/retired/child", "/shape", "/shape/0"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("historical paths = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if _, ok := metadata.Lookup(paths[4]); ok {
|
||||||
|
t.Fatal("removed descendant remains effective")
|
||||||
|
}
|
||||||
|
if _, ok := metadata.Lookup(paths[6]); !ok {
|
||||||
|
t.Fatal("enumerated numeric path cannot query the new sequence shape")
|
||||||
|
}
|
||||||
|
// Returned slices and nested paths must not alias retained metadata.
|
||||||
|
paths[1].segments[0].key = "mutated"
|
||||||
|
paths[2] = Keys("replacement")
|
||||||
|
if again := metadata.HistoricalPaths(); again[1].String() != want[1] || again[2].String() != want[2] {
|
||||||
|
t.Fatal("enumeration returned aliased paths")
|
||||||
|
}
|
||||||
|
if len((Metadata{}).HistoricalPaths()) != 0 {
|
||||||
|
t.Fatal("zero metadata contains paths")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoricalPathsExposeHiddenBaseOwnershipWithoutResolvingBaseSecrets(t *testing.T) {
|
||||||
|
t.Setenv("CONFIGKIT_HISTORY_UNUSED_PROD_KEY", "")
|
||||||
|
t.Setenv("CONFIGKIT_HISTORY_TEST_KEY", "synthetic-test-key")
|
||||||
|
type config struct {
|
||||||
|
Credential *Secret `yaml:"credential"`
|
||||||
|
Definitions map[string]map[string]string `yaml:"definitions"`
|
||||||
|
}
|
||||||
|
for _, credential := range []string{"CONFIGKIT_HISTORY_TEST_KEY", "null"} {
|
||||||
|
t.Run(credential, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
root := writeImportFile(t, dir, "main.yml", "configkit:\n imports: [{path: first.yml}, {path: second.yml}]\ncredential: CONFIGKIT_HISTORY_UNUSED_PROD_KEY\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, "overlays/testing.yml", "definitions: null\ncredential: "+credential+"\n")
|
||||||
|
result, err := testLoad[config](File(root), Options[config]{Imports: true, Overlay: "testing"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
metadata := result.Metadata()
|
||||||
|
found := false
|
||||||
|
for _, path := range metadata.HistoricalPaths() {
|
||||||
|
if path.String() != "/definitions/recap" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
owners := map[string]bool{}
|
||||||
|
for _, change := range metadata.History(path) {
|
||||||
|
if change.Origin.Layer == LayerBase {
|
||||||
|
for _, location := range change.Origin.Locations {
|
||||||
|
owners[location.SourceID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(owners) != 2 {
|
||||||
|
t.Fatalf("hidden declaration owners = %v", owners)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("hidden base declaration was not discoverable")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetadataQueriesReturnDetachedRecords(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := Keys("service", "workers")
|
||||||
|
metadata := Metadata{
|
||||||
|
effectivePaths: map[string]Path{path.String(): path},
|
||||||
|
historicalPaths: map[string][]Path{path.String(): {path}},
|
||||||
|
fields: map[string]FieldInfo{
|
||||||
|
path.String(): {
|
||||||
|
Explicit: true,
|
||||||
|
Origin: Origin{
|
||||||
|
Layer: LayerBase,
|
||||||
|
Locations: []Location{{SourceID: "source-1", Line: 2, Column: 3}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
history: map[string][]Change{
|
||||||
|
path.String(): {{
|
||||||
|
Path: path,
|
||||||
|
Origin: Origin{Layer: LayerResolution, Locations: []Location{{SourceID: "source-1"}}},
|
||||||
|
Previous: []Origin{{Layer: LayerDefault, Locations: []Location{{SourceID: "default"}}}},
|
||||||
|
Reasons: []string{"bounded worker count"},
|
||||||
|
Dependencies: []Path{Keys("limits")},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
info, ok := metadata.Lookup(path)
|
||||||
|
if !ok || !info.Explicit || !metadata.IsExplicit(path) {
|
||||||
|
t.Fatalf("Lookup() = %#v, %v", info, ok)
|
||||||
|
}
|
||||||
|
info.Origin.Locations[0].SourceID = "mutated"
|
||||||
|
again, _ := metadata.Lookup(path)
|
||||||
|
if again.Origin.Locations[0].SourceID != "source-1" {
|
||||||
|
t.Fatal("Lookup returned aliased locations")
|
||||||
|
}
|
||||||
|
|
||||||
|
history := metadata.History(path)
|
||||||
|
history[0].Reasons[0] = "mutated"
|
||||||
|
history[0].Previous[0].Locations[0].SourceID = "mutated"
|
||||||
|
history[0].Dependencies[0].segments[0].key = "mutated"
|
||||||
|
againHistory := metadata.History(path)
|
||||||
|
if againHistory[0].Reasons[0] != "bounded worker count" ||
|
||||||
|
againHistory[0].Previous[0].Locations[0].SourceID != "default" ||
|
||||||
|
againHistory[0].Dependencies[0].String() != "/limits" {
|
||||||
|
t.Fatal("History returned aliased nested records")
|
||||||
|
}
|
||||||
|
|
||||||
|
missing := Keys("missing")
|
||||||
|
if _, ok := metadata.Lookup(missing); ok || metadata.IsExplicit(missing) || metadata.History(missing) != nil {
|
||||||
|
t.Fatal("unknown path produced metadata")
|
||||||
|
}
|
||||||
|
if _, ok := metadata.Lookup(Path{}.Index(-1)); ok {
|
||||||
|
t.Fatal("invalid path produced metadata")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolutionInputKeepsDetachedEntryMetadata(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type configuration struct {
|
||||||
|
Default string `yaml:"default"`
|
||||||
|
Items []string `yaml:"items"`
|
||||||
|
Values map[string]string `yaml:"values"`
|
||||||
|
Nullable *string `yaml:"nullable"`
|
||||||
|
}
|
||||||
|
schema := mustSchemaFor[configuration](t)
|
||||||
|
document := parseDocumentForDecode(t, "items: [first]\nvalues: {'0': key, 'a/b~': escaped}\nnullable: null\n")
|
||||||
|
decoded, issues := decodeConfiguration(schema, reflect.ValueOf(configuration{Default: "retained"}), document)
|
||||||
|
if len(issues) != 0 {
|
||||||
|
t.Fatalf("decode issues = %#v", issues)
|
||||||
|
}
|
||||||
|
metadata := metadataFromDecoded(decoded)
|
||||||
|
input := newResolutionInput(metadata, decoded.final, []SourceInfo{{
|
||||||
|
ID: "source-1", Label: "decode.yml", Layer: LayerBase,
|
||||||
|
ImportedFrom: &Location{SourceID: "parent", Line: 1, Column: 1},
|
||||||
|
}})
|
||||||
|
|
||||||
|
parsedItem, err := ParsePath("/items/0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
parsedKey, err := ParsePath("/values/0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range []Path{Path{}, Keys("default"), Keys("nullable"), parsedItem, parsedKey, Keys("values", "a/b~")} {
|
||||||
|
if _, ok := input.lookup(path); !ok {
|
||||||
|
t.Fatalf("entry path %s is missing", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defaultInfo, _ := input.lookup(Keys("default"))
|
||||||
|
nullInfo, _ := input.lookup(Keys("nullable"))
|
||||||
|
if defaultInfo.Explicit || !nullInfo.Explicit || !nullInfo.Null {
|
||||||
|
t.Fatalf("default = %#v, nullable = %#v", defaultInfo, nullInfo)
|
||||||
|
}
|
||||||
|
for _, path := range []Path{
|
||||||
|
Keys("default", "child"),
|
||||||
|
Keys("items", "0"),
|
||||||
|
Keys("values").Index(0),
|
||||||
|
Keys("items").Index(1),
|
||||||
|
} {
|
||||||
|
if _, ok := input.lookup(path); ok {
|
||||||
|
t.Fatalf("entry lookup accepted %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item, _ := input.lookup(parsedItem)
|
||||||
|
item.Origin.Locations[0].SourceID = "mutated"
|
||||||
|
source, ok := input.source("source-1")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("source is missing")
|
||||||
|
}
|
||||||
|
source.ImportedFrom.SourceID = "mutated"
|
||||||
|
|
||||||
|
configurationAtEntry := decoded.value.Interface().(configuration)
|
||||||
|
resolved := runResolution(&configurationAtEntry, func(value *configuration, resolution *Resolution) error {
|
||||||
|
value.Items[0] = "normalized"
|
||||||
|
resolution.Record(Keys("items"), ChangeNormalization, "normalized item")
|
||||||
|
return nil
|
||||||
|
}, schema, decoded, document, input)
|
||||||
|
if len(resolved.diagnostics) != 0 {
|
||||||
|
t.Fatalf("resolution diagnostics = %#v", resolved.diagnostics)
|
||||||
|
}
|
||||||
|
applyResolutionMetadata(&metadata, resolved)
|
||||||
|
metadata.effectivePaths = effectivePathIndex(resolved.final)
|
||||||
|
|
||||||
|
again, ok := input.lookup(parsedItem)
|
||||||
|
if !ok || again.Origin.Locations[0].SourceID != "source-1" || again.Origin.Operation != "" {
|
||||||
|
t.Fatalf("entry metadata changed after resolution = %#v, %v", again, ok)
|
||||||
|
}
|
||||||
|
againSource, ok := input.source("source-1")
|
||||||
|
if !ok || againSource.ImportedFrom == nil || againSource.ImportedFrom.SourceID != "parent" {
|
||||||
|
t.Fatalf("entry source changed = %#v, %v", againSource, ok)
|
||||||
|
}
|
||||||
|
if _, ok := input.source(""); ok {
|
||||||
|
t.Fatal("empty source identity resolved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueriesRespectActualHistoricalAndEffectiveContainers(t *testing.T) {
|
||||||
|
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 {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range []Path{Keys("items", "0"), Keys("m").Index(0), Keys("x", "missing"), Keys("items").Index(8)} {
|
||||||
|
if _, ok := result.Metadata().Lookup(path); ok {
|
||||||
|
t.Fatalf("invalid lookup %s", path)
|
||||||
|
}
|
||||||
|
if result.Metadata().History(path) != nil {
|
||||||
|
t.Fatalf("invalid history %s", path)
|
||||||
|
}
|
||||||
|
if _, ok := result.Explain(path); ok {
|
||||||
|
t.Fatalf("invalid explanation %s", path)
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := result.WriteExplanation(&out, path); err == nil || out.Len() != 0 {
|
||||||
|
t.Fatalf("invalid path wrote output: %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, pointer := range []string{"/items/0", "/m/0"} {
|
||||||
|
path, _ := ParsePath(pointer)
|
||||||
|
if _, ok := result.Metadata().Lookup(path); !ok {
|
||||||
|
t.Fatal("parsed path lost lookup")
|
||||||
|
}
|
||||||
|
if _, ok := result.Explain(path); !ok {
|
||||||
|
t.Fatal("parsed path lost explanation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
retired := Keys("retired", "child")
|
||||||
|
if _, ok := result.Metadata().Lookup(retired); ok {
|
||||||
|
t.Fatal("removed path remains effective")
|
||||||
|
}
|
||||||
|
if len(result.Metadata().History(retired)) == 0 {
|
||||||
|
t.Fatal("removed history missing")
|
||||||
|
}
|
||||||
|
x, ok := result.Explain(retired)
|
||||||
|
if !ok || x.Effective.Present || len(x.History) == 0 {
|
||||||
|
t.Fatal("removed explanation missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
41
internal/engine/options.go
Normal file
41
internal/engine/options.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []Diagnostic) {
|
||||||
|
if len(supplied) > 1 {
|
||||||
|
return Options[T]{}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "Load accepts at most one options value")}
|
||||||
|
}
|
||||||
|
var options Options[T]
|
||||||
|
if len(supplied) == 1 {
|
||||||
|
options = supplied[0]
|
||||||
|
}
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
definition := options.Definition
|
||||||
|
if !validOverlayName(options.Overlay) {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "overlay name must contain only ASCII letters, digits, underscore, or hyphen"))
|
||||||
|
}
|
||||||
|
if options.Filesystem != nil {
|
||||||
|
diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...)
|
||||||
|
}
|
||||||
|
if source.kind == sourceNone && options.Imports {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot enable imports"))
|
||||||
|
}
|
||||||
|
if source.kind == sourceNone && options.Overlay != "" && options.Overlay != "default" {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active overlay"))
|
||||||
|
}
|
||||||
|
if options.Filesystem == nil && (source.kind == sourceBytes || source.kind == sourceReader) && (options.Imports || activeOverlay(options.Overlay)) {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "byte and reader imports or overlays require filesystem context"))
|
||||||
|
}
|
||||||
|
if definition.Version != nil {
|
||||||
|
if !definition.Version.Path.valid() {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version path is invalid"))
|
||||||
|
}
|
||||||
|
if definition.Version.Check == nil {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version rule requires a checker"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options, canonicalDiagnostics(diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionDiagnostic(code Code, message string) Diagnostic {
|
||||||
|
return Diagnostic{Code: code, Severity: SeverityError, Message: message}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"slices"
|
"slices"
|
||||||
@@ -10,8 +10,6 @@ import (
|
|||||||
type overlayRecord struct {
|
type overlayRecord struct {
|
||||||
change Change
|
change Change
|
||||||
displaced []displacedDefinition
|
displaced []displacedDefinition
|
||||||
before *document.Node
|
|
||||||
after *document.Node
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type displacedDefinition struct {
|
type displacedDefinition struct {
|
||||||
@@ -22,22 +20,14 @@ type displacedDefinition struct {
|
|||||||
func overlayDocuments(base *composedDocument, overlay *document.Node) (*composedDocument, []Diagnostic) {
|
func overlayDocuments(base *composedDocument, overlay *document.Node) (*composedDocument, []Diagnostic) {
|
||||||
if base == nil || base.root == nil || base.root.Kind != document.Mapping || overlay == nil || overlay.Kind != document.Mapping {
|
if base == nil || base.root == nil || base.root.Kind != document.Mapping || overlay == nil || overlay.Kind != document.Mapping {
|
||||||
return nil, []Diagnostic{{
|
return nil, []Diagnostic{{
|
||||||
Code: CodeProfileInvalid, Severity: SeverityError,
|
Code: CodeOverlayInvalid, Severity: SeverityError,
|
||||||
Message: "profile overlay requires mapping document roots",
|
Message: "overlay requires mapping document roots",
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
result := &composedDocument{
|
result := cloneComposedDocument(base)
|
||||||
root: document.Clone(base.root),
|
mergeOverlayOrigin(result.origins, Path{}, overlay.Location, LayerOverlay)
|
||||||
claims: cloneCompositionClaims(base.claims),
|
mergeOverlayMappings(result, result.root, overlay, Path{}, LayerOverlay, true)
|
||||||
origins: cloneOriginIndex(base.origins),
|
result.applications = append(result.applications, historyApplication{layer: LayerOverlay, value: overlay})
|
||||||
overlays: cloneOverlayRecords(base.overlays),
|
|
||||||
applications: cloneHistoryApplications(base.applications),
|
|
||||||
inputBytes: base.inputBytes,
|
|
||||||
inputNodes: base.inputNodes,
|
|
||||||
}
|
|
||||||
mergeOverlayOrigin(result.origins, Path{}, overlay.Location, LayerProfile)
|
|
||||||
mergeOverlayMappings(result, result.root, overlay, Path{}, LayerProfile, true)
|
|
||||||
result.applications = append(result.applications, historyApplication{layer: LayerProfile, value: overlay})
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +63,6 @@ func mergeOverlayMappings(result *composedDocument, target, overlay *document.No
|
|||||||
Previous: previousOrigins(previous),
|
Previous: previousOrigins(previous),
|
||||||
},
|
},
|
||||||
displaced: displaced,
|
displaced: displaced,
|
||||||
before: document.Clone(existing), after: document.Clone(incoming.Value),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,8 +150,6 @@ func cloneOverlayRecords(records []overlayRecord) []overlayRecord {
|
|||||||
cloned := make([]overlayRecord, len(records))
|
cloned := make([]overlayRecord, len(records))
|
||||||
for index, record := range records {
|
for index, record := range records {
|
||||||
cloned[index].change = cloneChange(record.change)
|
cloned[index].change = cloneChange(record.change)
|
||||||
cloned[index].before = document.Clone(record.before)
|
|
||||||
cloned[index].after = document.Clone(record.after)
|
|
||||||
cloned[index].displaced = make([]displacedDefinition, len(record.displaced))
|
cloned[index].displaced = make([]displacedDefinition, len(record.displaced))
|
||||||
for displacedIndex, displaced := range record.displaced {
|
for displacedIndex, displaced := range record.displaced {
|
||||||
cloned[index].displaced[displacedIndex] = displacedDefinition{
|
cloned[index].displaced[displacedIndex] = displacedDefinition{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -22,7 +22,7 @@ to_scalar: {child: old}
|
|||||||
}
|
}
|
||||||
overlay := parseCompositionDocument(t, "source-2", `shared:
|
overlay := parseCompositionDocument(t, "source-2", `shared:
|
||||||
changed: new
|
changed: new
|
||||||
added: profile
|
added: overlay
|
||||||
sequence: []
|
sequence: []
|
||||||
nullable: null
|
nullable: null
|
||||||
to_map: {child: new}
|
to_map: {child: new}
|
||||||
@@ -42,7 +42,7 @@ to_scalar: scalar
|
|||||||
}{
|
}{
|
||||||
{path: Keys("shared", "kept"), kind: document.String, text: "base"},
|
{path: Keys("shared", "kept"), kind: document.String, text: "base"},
|
||||||
{path: Keys("shared", "changed"), kind: document.String, text: "new"},
|
{path: Keys("shared", "changed"), kind: document.String, text: "new"},
|
||||||
{path: Keys("shared", "added"), kind: document.String, text: "profile"},
|
{path: Keys("shared", "added"), kind: document.String, text: "overlay"},
|
||||||
{path: Keys("sequence"), kind: document.Sequence},
|
{path: Keys("sequence"), kind: document.Sequence},
|
||||||
{path: Keys("nullable"), kind: document.Null},
|
{path: Keys("nullable"), kind: document.Null},
|
||||||
{path: Keys("to_map"), kind: document.Mapping},
|
{path: Keys("to_map"), kind: document.Mapping},
|
||||||
@@ -62,7 +62,7 @@ to_scalar: scalar
|
|||||||
t.Fatal("parent replacement retained a stale child origin")
|
t.Fatal("parent replacement retained a stale child origin")
|
||||||
}
|
}
|
||||||
shared := result.origins["/shared"]
|
shared := result.origins["/shared"]
|
||||||
if shared.Layer != LayerProfile || len(shared.Locations) != 2 {
|
if shared.Layer != LayerOverlay || len(shared.Locations) != 2 {
|
||||||
t.Fatalf("shared mapping origin = %#v", shared)
|
t.Fatalf("shared mapping origin = %#v", shared)
|
||||||
}
|
}
|
||||||
if len(result.overlays) != 5 {
|
if len(result.overlays) != 5 {
|
||||||
223
internal/engine/override.go
Normal file
223
internal/engine/override.go
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxOverrides = 1024
|
||||||
|
|
||||||
|
type preparedOverride struct {
|
||||||
|
path Path
|
||||||
|
value *document.Node
|
||||||
|
source SourceInfo
|
||||||
|
bytes int64
|
||||||
|
nodes int
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareOverrides(base *composedDocument, sources []SourceInfo, overrides []Override, rule *VersionRule, limits importLimits) ([]preparedOverride, []SourceInfo, []Diagnostic) {
|
||||||
|
if len(overrides) > maxOverrides || len(sources)+len(overrides) > limits.sources {
|
||||||
|
return nil, cloneSourceInfos(sources), []Diagnostic{{
|
||||||
|
Code: CodeOverrideLimitExceeded, Severity: SeverityError,
|
||||||
|
Message: "configuration overrides exceed the record or source-count limit",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
for index, override := range overrides {
|
||||||
|
if !validOverrideLabel(override.Flag) {
|
||||||
|
diagnostics = append(diagnostics, Diagnostic{
|
||||||
|
Code: CodeOverrideInvalidLabel, Severity: SeverityError,
|
||||||
|
Message: "override record " + strconv.Itoa(index+1) + " requires a safe nonempty flag name",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, cloneSourceInfos(sources), canonicalDiagnostics(diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
prepared := make([]preparedOverride, len(overrides))
|
||||||
|
allSources := cloneSourceInfos(sources)
|
||||||
|
for index, override := range overrides {
|
||||||
|
sourceID := "source-" + strconv.Itoa(len(sources)+index+1)
|
||||||
|
info := SourceInfo{ID: sourceID, Label: override.Flag, Layer: LayerCLI}
|
||||||
|
allSources = append(allSources, info)
|
||||||
|
prepared[index].source = info
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]int, len(overrides))
|
||||||
|
pathAccepted := make([]bool, len(overrides))
|
||||||
|
var usedBytes int64
|
||||||
|
usedNodes := 0
|
||||||
|
for index, override := range overrides {
|
||||||
|
location := &Location{SourceID: prepared[index].source.ID}
|
||||||
|
if issue := validateOverridePath(override.Path, base.root); issue != nil {
|
||||||
|
issue.Primary = location
|
||||||
|
diagnostics = append(diagnostics, *issue)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pointer := override.Path.String()
|
||||||
|
if previous, duplicate := seen[pointer]; duplicate {
|
||||||
|
diagnostics = append(diagnostics, Diagnostic{
|
||||||
|
Code: CodeOverrideDuplicate, Severity: SeverityError, Path: override.Path.clone(),
|
||||||
|
Message: "configuration override path is repeated", Primary: location,
|
||||||
|
Related: []Location{{SourceID: prepared[previous].source.ID}},
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[pointer] = index
|
||||||
|
if conflicting := overlappingOverride(overrides[:index], pathAccepted[:index], override.Path); conflicting >= 0 {
|
||||||
|
diagnostics = append(diagnostics, Diagnostic{
|
||||||
|
Code: CodeOverrideOverlap, Severity: SeverityError, Path: override.Path.clone(),
|
||||||
|
Message: "configuration override paths overlap", Primary: location,
|
||||||
|
Related: []Location{{SourceID: prepared[conflicting].source.ID}},
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rule != nil && (pathContains(override.Path, rule.Path) || pathContains(rule.Path, override.Path)) {
|
||||||
|
diagnostics = append(diagnostics, Diagnostic{
|
||||||
|
Code: CodeOverrideVersion, Severity: SeverityError, Path: override.Path.clone(),
|
||||||
|
Message: "configuration overrides cannot replace the schema-version path", Primary: location,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pathContainsDirective(override.Path) {
|
||||||
|
diagnostics = append(diagnostics, Diagnostic{
|
||||||
|
Code: CodeOverrideDirective, Severity: SeverityError, Path: override.Path.clone(),
|
||||||
|
Message: "configuration overrides cannot create configuration directives", Primary: location,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pathAccepted[index] = true
|
||||||
|
|
||||||
|
builder := overrideValueBuilder{sourceID: prepared[index].source.ID, active: make(map[valueVisit]bool)}
|
||||||
|
value, issue := builder.build(override.Value, override.Path, len(override.Path.segments))
|
||||||
|
if issue != nil {
|
||||||
|
issue.Primary = location
|
||||||
|
diagnostics = append(diagnostics, *issue)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if relative, ok := overrideDirectivePath(value, Path{}); ok {
|
||||||
|
diagnostics = append(diagnostics, Diagnostic{
|
||||||
|
Code: CodeOverrideDirective, Severity: SeverityError, Path: appendOverridePath(override.Path, relative),
|
||||||
|
Message: "configuration overrides cannot create configuration directives", Primary: location,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nodes := builder.nodes + len(override.Path.segments)*2
|
||||||
|
if nodes > limits.nodes || base.inputNodes+usedNodes+nodes > limits.nodes {
|
||||||
|
diagnostics = append(diagnostics, Diagnostic{
|
||||||
|
Code: CodeOverrideLimitExceeded, Severity: SeverityError, Path: override.Path.clone(),
|
||||||
|
Message: "configuration overrides exceed the aggregate node limit", Primary: location,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if builder.bytes > maxSourceBytes || base.inputBytes+usedBytes+builder.bytes > limits.bytes {
|
||||||
|
diagnostics = append(diagnostics, Diagnostic{
|
||||||
|
Code: CodeOverrideLimitExceeded, Severity: SeverityError, Path: override.Path.clone(),
|
||||||
|
Message: "configuration overrides exceed a record or aggregate string-byte limit", Primary: location,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prepared[index].path = override.Path.clone()
|
||||||
|
prepared[index].value = value
|
||||||
|
prepared[index].bytes = builder.bytes
|
||||||
|
prepared[index].nodes = nodes
|
||||||
|
usedBytes += builder.bytes
|
||||||
|
usedNodes += nodes
|
||||||
|
}
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, allSources, canonicalDiagnostics(diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
return prepared, allSources, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validOverrideLabel(label string) bool {
|
||||||
|
if label == "" || strings.TrimSpace(label) != label || !utf8.ValidString(label) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, character := range label {
|
||||||
|
if unicode.IsControl(character) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateOverridePath(path Path, root *document.Node) *Diagnostic {
|
||||||
|
if !path.valid() || len(path.segments) == 0 {
|
||||||
|
return &Diagnostic{Code: CodeOverrideInvalidPath, Severity: SeverityError, Path: path.clone(), Message: "configuration override path must be a valid non-root mapping path"}
|
||||||
|
}
|
||||||
|
current := root
|
||||||
|
for index, segment := range path.segments {
|
||||||
|
if segment.kind == pathSegmentIndex {
|
||||||
|
return &Diagnostic{Code: CodeOverrideInvalidPath, Severity: SeverityError, Path: path.clone(), Message: "configuration override paths cannot contain sequence indices"}
|
||||||
|
}
|
||||||
|
if current == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if current.Kind != document.Mapping {
|
||||||
|
return &Diagnostic{Code: CodeOverrideInvalidPath, Severity: SeverityError, Path: path.clone(), Message: "configuration override path traverses a non-mapping value"}
|
||||||
|
}
|
||||||
|
position := documentEntryIndex(current.Mapping, segment.key)
|
||||||
|
if index == len(path.segments)-1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if position < 0 {
|
||||||
|
current = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current = current.Mapping[position].Value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func overlappingOverride(previous []Override, valid []bool, path Path) int {
|
||||||
|
for index, override := range previous {
|
||||||
|
if valid[index] && (pathContains(override.Path, path) || pathContains(path, override.Path)) {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathContainsDirective(path Path) bool {
|
||||||
|
for _, segment := range path.segments {
|
||||||
|
if segment.key == directiveKey {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendOverridePath(base, relative Path) Path {
|
||||||
|
result := base.clone()
|
||||||
|
result.segments = append(result.segments, clonePathSegments(relative.segments, 0)...)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func overrideDirectivePath(node *document.Node, path Path) (Path, bool) {
|
||||||
|
if node.Kind == document.Mapping {
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
child := path.Key(entry.Key)
|
||||||
|
if entry.Key == directiveKey {
|
||||||
|
return child, true
|
||||||
|
}
|
||||||
|
if found, ok := overrideDirectivePath(entry.Value, child); ok {
|
||||||
|
return found, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if node.Kind == document.Sequence {
|
||||||
|
for index, child := range node.Sequence {
|
||||||
|
if found, ok := overrideDirectivePath(child, path.Index(index)); ok {
|
||||||
|
return found, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Path{}, false
|
||||||
|
}
|
||||||
100
internal/engine/override_apply.go
Normal file
100
internal/engine/override_apply.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
func applyOverrides(base *composedDocument, sources []SourceInfo, overrides []Override, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
|
if len(overrides) == 0 {
|
||||||
|
return base, cloneSourceInfos(sources), nil
|
||||||
|
}
|
||||||
|
prepared, allSources, diagnostics := prepareOverrides(base, sources, overrides, rule, limits)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, allSources, diagnostics
|
||||||
|
}
|
||||||
|
result := cloneComposedDocument(base)
|
||||||
|
for _, override := range prepared {
|
||||||
|
applyPreparedOverride(result, override)
|
||||||
|
result.inputBytes += override.bytes
|
||||||
|
result.inputNodes += override.nodes
|
||||||
|
}
|
||||||
|
return result, allSources, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyPreparedOverride(result *composedDocument, override preparedOverride) {
|
||||||
|
location := document.Location{SourceID: override.source.ID}
|
||||||
|
mergeOverlayOrigin(result.origins, Path{}, location, LayerCLI)
|
||||||
|
current := result.root
|
||||||
|
currentPath := Path{}
|
||||||
|
for index, segment := range override.path.segments {
|
||||||
|
path := currentPath.Key(segment.key)
|
||||||
|
position := documentEntryIndex(current.Mapping, segment.key)
|
||||||
|
if index < len(override.path.segments)-1 {
|
||||||
|
if position < 0 {
|
||||||
|
child := &document.Node{Kind: document.Mapping, Location: location, Mapping: []document.Entry{}}
|
||||||
|
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, KeyLocation: location, ValueLocation: location, Value: child})
|
||||||
|
collectOverlayOrigins(result.origins, child, path, LayerCLI)
|
||||||
|
current = child
|
||||||
|
} else {
|
||||||
|
current = current.Mapping[position].Value
|
||||||
|
mergeOverlayOrigin(result.origins, path, location, LayerCLI)
|
||||||
|
}
|
||||||
|
currentPath = path
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
previous := cloneOrigin(result.origins[path.String()])
|
||||||
|
var displaced []displacedDefinition
|
||||||
|
if position < 0 {
|
||||||
|
incoming := document.Clone(override.value)
|
||||||
|
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, KeyLocation: location, ValueLocation: location, Value: incoming})
|
||||||
|
collectOverlayOrigins(result.origins, incoming, path, LayerCLI)
|
||||||
|
} else {
|
||||||
|
existing := current.Mapping[position].Value
|
||||||
|
displaced = affectedOverrideOrigins(existing, override.value, result.origins, path)
|
||||||
|
if existing.Kind == document.Mapping && override.value.Kind == document.Mapping {
|
||||||
|
mergeOverlayOrigin(result.origins, path, location, LayerCLI)
|
||||||
|
mergeOverlayMappings(result, existing, override.value, path, LayerCLI, false)
|
||||||
|
} else {
|
||||||
|
deleteOriginSubtree(result.origins, path)
|
||||||
|
current.Mapping[position] = document.Entry{Key: segment.key, KeyLocation: location, ValueLocation: location, Value: document.Clone(override.value)}
|
||||||
|
collectOverlayOrigins(result.origins, current.Mapping[position].Value, path, LayerCLI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.overlays = append(result.overlays, overlayRecord{change: Change{
|
||||||
|
Path: path.clone(), Origin: overlayOrigin(LayerCLI, location), Previous: previousOrigins(previous),
|
||||||
|
}, displaced: displaced})
|
||||||
|
result.applications = append(result.applications, historyApplication{
|
||||||
|
layer: LayerCLI, path: path.clone(), value: override.value,
|
||||||
|
origin: overlayOrigin(LayerCLI, location),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func affectedOverrideOrigins(existing, incoming *document.Node, origins map[string]Origin, path Path) []displacedDefinition {
|
||||||
|
displaced := []displacedDefinition{{path: path.clone(), origin: cloneOrigin(origins[path.String()])}}
|
||||||
|
if existing.Kind == document.Mapping && incoming.Kind == document.Mapping {
|
||||||
|
for _, entry := range incoming.Mapping {
|
||||||
|
position := documentEntryIndex(existing.Mapping, entry.Key)
|
||||||
|
if position < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
childPath := path.Key(entry.Key)
|
||||||
|
childExisting := existing.Mapping[position].Value
|
||||||
|
if childExisting.Kind == document.Mapping && entry.Value.Kind == document.Mapping {
|
||||||
|
displaced = append(displaced, affectedOverrideOrigins(childExisting, entry.Value, origins, childPath)...)
|
||||||
|
} else {
|
||||||
|
displaced = append(displaced, displacedOrigins(childExisting, origins, childPath)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
displaced = displacedOrigins(existing, origins, path)
|
||||||
|
}
|
||||||
|
slices.SortFunc(displaced, func(left, right displacedDefinition) int {
|
||||||
|
return strings.Compare(left.path.String(), right.path.String())
|
||||||
|
})
|
||||||
|
return displaced
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -30,7 +30,7 @@ type overrideLifecycleConfig struct {
|
|||||||
|
|
||||||
func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *testing.T) {
|
func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports: [{path: base.yml}]\nversion: v1\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports: [{path: base.yml}]\nversion: v1\n")
|
||||||
writeImportFile(t, directory, "base.yml", `settings:
|
writeImportFile(t, directory, "base.yml", `settings:
|
||||||
target: base
|
target: base
|
||||||
count: 4
|
count: 4
|
||||||
@@ -41,9 +41,9 @@ func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *te
|
|||||||
labels: {base: retained}
|
labels: {base: retained}
|
||||||
api_key: BASE_SECRET
|
api_key: BASE_SECRET
|
||||||
`)
|
`)
|
||||||
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), `settings:
|
writeImportFile(t, directory, filepath.Join("overlays", "testing.yml"), `settings:
|
||||||
target: profile
|
target: overlay
|
||||||
labels: {profile: retained}
|
labels: {overlay: retained}
|
||||||
`)
|
`)
|
||||||
validated := false
|
validated := false
|
||||||
overrides := []Override{
|
overrides := []Override{
|
||||||
@@ -56,13 +56,13 @@ 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)
|
||||||
}
|
}
|
||||||
return "credential-value", true
|
return "credential-value", true
|
||||||
}, Options[overrideLifecycleConfig]{
|
}, Options[overrideLifecycleConfig]{
|
||||||
Imports: true, Profile: "testing", Overrides: overrides,
|
Imports: true, Overlay: "testing", Overrides: overrides,
|
||||||
Definition: Definition[overrideLifecycleConfig]{
|
Definition: Definition[overrideLifecycleConfig]{
|
||||||
Defaults: func() overrideLifecycleConfig {
|
Defaults: func() overrideLifecycleConfig {
|
||||||
return overrideLifecycleConfig{Settings: overrideLifecycleSettings{Target: "default", Count: 9, Text: "default", Items: []string{"default"}}}
|
return overrideLifecycleConfig{Settings: overrideLifecycleSettings{Target: "default", Count: 9, Text: "default", Items: []string{"default"}}}
|
||||||
@@ -83,10 +83,10 @@ 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", "overlay": "retained", "cli": "selected"},
|
||||||
APIKey: result.Config.Settings.APIKey,
|
APIKey: result.Config.Settings.APIKey,
|
||||||
}
|
}
|
||||||
if !validated || result.Config.Settings.Count != 0 || result.Config.Settings.Enabled || result.Config.Settings.Text != "" ||
|
if !validated || result.Config.Settings.Count != 0 || result.Config.Settings.Enabled || result.Config.Settings.Text != "" ||
|
||||||
@@ -95,7 +95,7 @@ func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *te
|
|||||||
t.Fatalf("effective settings = %#v", result.Config.Settings)
|
t.Fatalf("effective settings = %#v", result.Config.Settings)
|
||||||
}
|
}
|
||||||
sources := result.Sources()
|
sources := result.Sources()
|
||||||
if len(sources) != 3+len(overrides) || sources[0].Layer != LayerBase || sources[1].Layer != LayerBase || sources[2].Layer != LayerProfile {
|
if len(sources) != 3+len(overrides) || sources[0].Layer != LayerBase || sources[1].Layer != LayerBase || sources[2].Layer != LayerOverlay {
|
||||||
t.Fatalf("sources = %#v", sources)
|
t.Fatalf("sources = %#v", sources)
|
||||||
}
|
}
|
||||||
for index, override := range overrides {
|
for index, override := range overrides {
|
||||||
@@ -104,7 +104,7 @@ func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *te
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
history := result.Metadata().History(Keys("settings", "target"))
|
history := result.Metadata().History(Keys("settings", "target"))
|
||||||
wantLayers := []Layer{LayerDefault, LayerBase, LayerProfile, LayerCLI, LayerResolution}
|
wantLayers := []Layer{LayerDefault, LayerBase, LayerOverlay, LayerCLI, LayerResolution}
|
||||||
if got := historyLayers(history); !reflect.DeepEqual(got, wantLayers) {
|
if got := historyLayers(history); !reflect.DeepEqual(got, wantLayers) {
|
||||||
t.Fatalf("target history layers = %#v, want %#v; history %#v", got, wantLayers, history)
|
t.Fatalf("target history layers = %#v, want %#v; history %#v", got, wantLayers, history)
|
||||||
}
|
}
|
||||||
@@ -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}}
|
||||||
}},
|
}},
|
||||||
@@ -163,7 +163,7 @@ func TestOverrideValidationRejectsPathsOverlapValuesAndProtectedTargets(t *testi
|
|||||||
Nested map[string]any `yaml:"nested"`
|
Nested map[string]any `yaml:"nested"`
|
||||||
Unknown string `yaml:"unknown"`
|
Unknown string `yaml:"unknown"`
|
||||||
}
|
}
|
||||||
base := Bytes("override-errors.yml", []byte("version: v1\nscalar: value\nitems: [one]\nnested: {}\n"))
|
base := Bytes("override-errors.yml", []byte("version: 4\nscalar: value\nitems: [one]\nnested: {}\n"))
|
||||||
version := &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }}
|
version := &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }}
|
||||||
cycle := map[string]any{}
|
cycle := map[string]any{}
|
||||||
cycle["self"] = cycle
|
cycle["self"] = cycle
|
||||||
@@ -187,14 +187,14 @@ func TestOverrideValidationRejectsPathsOverlapValuesAndProtectedTargets(t *testi
|
|||||||
{name: "nonfinite", overrides: []Override{{Path: Keys("scalar"), Value: math.Inf(1), Flag: "--scalar"}}, code: CodeOverrideInvalidValue},
|
{name: "nonfinite", overrides: []Override{{Path: Keys("scalar"), Value: math.Inf(1), Flag: "--scalar"}}, code: CodeOverrideInvalidValue},
|
||||||
{name: "depth", overrides: []Override{{Path: Keys("nested"), Value: deep, Flag: "--nested"}}, code: CodeOverrideLimitExceeded},
|
{name: "depth", overrides: []Override{{Path: Keys("nested"), Value: deep, Flag: "--nested"}}, code: CodeOverrideLimitExceeded},
|
||||||
{name: "record bytes", overrides: []Override{{Path: Keys("scalar"), Value: strings.Repeat("x", maxSourceBytes+1), Flag: "--scalar"}}, code: CodeOverrideLimitExceeded},
|
{name: "record bytes", overrides: []Override{{Path: Keys("scalar"), Value: strings.Repeat("x", maxSourceBytes+1), Flag: "--scalar"}}, code: CodeOverrideLimitExceeded},
|
||||||
{name: "directive path", overrides: []Override{{Path: Keys("_configkit", "x"), Value: true, Flag: "--directive"}}, code: CodeOverrideDirective},
|
{name: "directive path", overrides: []Override{{Path: Keys("configkit", "x"), Value: true, Flag: "--directive"}}, code: CodeOverrideDirective},
|
||||||
{name: "directive value", overrides: []Override{{Path: Keys("nested"), Value: map[string]any{"_configkit": map[string]any{}}, Flag: "--nested"}}, code: CodeOverrideDirective},
|
{name: "directive value", overrides: []Override{{Path: Keys("nested"), Value: map[string]any{"configkit": map[string]any{}}, Flag: "--nested"}}, code: CodeOverrideDirective},
|
||||||
{name: "version", overrides: []Override{{Path: Keys("version"), Value: "v2", Flag: "--version"}}, code: CodeOverrideVersion},
|
{name: "version", overrides: []Override{{Path: Keys("version"), Value: "v2", Flag: "--version"}}, code: CodeOverrideVersion},
|
||||||
{name: "version ancestor", overrides: []Override{{Path: Keys("version"), Value: map[string]any{"child": "v2"}, Flag: "--version"}}, code: CodeOverrideVersion},
|
{name: "version ancestor", overrides: []Override{{Path: Keys("version"), Value: map[string]any{"child": "v2"}, Flag: "--version"}}, code: CodeOverrideVersion},
|
||||||
{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: v1}\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)
|
||||||
}
|
}
|
||||||
150
internal/engine/override_value.go
Normal file
150
internal/engine/override_value.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
type overrideValueBuilder struct {
|
||||||
|
sourceID string
|
||||||
|
active map[valueVisit]bool
|
||||||
|
bytes int64
|
||||||
|
nodes int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *overrideValueBuilder) build(value any, path Path, depth int) (*document.Node, *Diagnostic) {
|
||||||
|
if depth > maxDocumentDepth {
|
||||||
|
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the depth limit")
|
||||||
|
}
|
||||||
|
b.nodes++
|
||||||
|
if b.nodes > maxDocumentNodes {
|
||||||
|
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the node limit")
|
||||||
|
}
|
||||||
|
location := document.Location{SourceID: b.sourceID}
|
||||||
|
switch value := value.(type) {
|
||||||
|
case nil:
|
||||||
|
return &document.Node{Kind: document.Null, Location: location}, nil
|
||||||
|
case string:
|
||||||
|
if !b.addBytes(len(value)) {
|
||||||
|
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the record string-byte limit")
|
||||||
|
}
|
||||||
|
return &document.Node{Kind: document.String, Text: value, Location: location}, nil
|
||||||
|
case bool:
|
||||||
|
return &document.Node{Kind: document.Boolean, Text: strconv.FormatBool(value), Location: location}, nil
|
||||||
|
case int:
|
||||||
|
return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil
|
||||||
|
case int8:
|
||||||
|
return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil
|
||||||
|
case int16:
|
||||||
|
return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil
|
||||||
|
case int32:
|
||||||
|
return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil
|
||||||
|
case int64:
|
||||||
|
return overrideNumber(strconv.FormatInt(value, 10), location), nil
|
||||||
|
case uint:
|
||||||
|
return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil
|
||||||
|
case uint8:
|
||||||
|
return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil
|
||||||
|
case uint16:
|
||||||
|
return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil
|
||||||
|
case uint32:
|
||||||
|
return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil
|
||||||
|
case uint64:
|
||||||
|
return overrideNumber(strconv.FormatUint(value, 10), location), nil
|
||||||
|
case float32:
|
||||||
|
if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) {
|
||||||
|
return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override float must be finite")
|
||||||
|
}
|
||||||
|
return overrideNumber(strconv.FormatFloat(float64(value), 'g', -1, 32), location), nil
|
||||||
|
case float64:
|
||||||
|
if math.IsNaN(value) || math.IsInf(value, 0) {
|
||||||
|
return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override float must be finite")
|
||||||
|
}
|
||||||
|
return overrideNumber(strconv.FormatFloat(value, 'g', -1, 64), location), nil
|
||||||
|
case []any:
|
||||||
|
if value == nil {
|
||||||
|
return &document.Node{Kind: document.Null, Location: location}, nil
|
||||||
|
}
|
||||||
|
leave, diagnostic := b.enter(reflect.ValueOf(value), path)
|
||||||
|
if diagnostic != nil {
|
||||||
|
return nil, diagnostic
|
||||||
|
}
|
||||||
|
defer leave()
|
||||||
|
if len(value) > maxDocumentNodes-b.nodes {
|
||||||
|
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the node limit")
|
||||||
|
}
|
||||||
|
node := &document.Node{Kind: document.Sequence, Location: location, Sequence: make([]*document.Node, len(value))}
|
||||||
|
for index, child := range value {
|
||||||
|
built, diagnostic := b.build(child, path.Index(index), depth+1)
|
||||||
|
if diagnostic != nil {
|
||||||
|
return nil, diagnostic
|
||||||
|
}
|
||||||
|
node.Sequence[index] = built
|
||||||
|
}
|
||||||
|
return node, nil
|
||||||
|
case map[string]any:
|
||||||
|
if value == nil {
|
||||||
|
return &document.Node{Kind: document.Null, Location: location}, nil
|
||||||
|
}
|
||||||
|
leave, diagnostic := b.enter(reflect.ValueOf(value), path)
|
||||||
|
if diagnostic != nil {
|
||||||
|
return nil, diagnostic
|
||||||
|
}
|
||||||
|
defer leave()
|
||||||
|
if len(value) > (maxDocumentNodes-b.nodes)/2 {
|
||||||
|
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the node limit")
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(value))
|
||||||
|
for key := range value {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
slices.Sort(keys)
|
||||||
|
node := &document.Node{Kind: document.Mapping, Location: location, Mapping: make([]document.Entry, 0, len(keys))}
|
||||||
|
for _, key := range keys {
|
||||||
|
b.nodes++ // mapping key
|
||||||
|
if !b.addBytes(len(key)) {
|
||||||
|
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path.Key(key), "configuration override exceeds the record string-byte limit")
|
||||||
|
}
|
||||||
|
built, diagnostic := b.build(value[key], path.Key(key), depth+1)
|
||||||
|
if diagnostic != nil {
|
||||||
|
return nil, diagnostic
|
||||||
|
}
|
||||||
|
node.Mapping = append(node.Mapping, document.Entry{Key: key, KeyLocation: location, ValueLocation: location, Value: built})
|
||||||
|
}
|
||||||
|
return node, nil
|
||||||
|
default:
|
||||||
|
return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override value has an unsupported type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *overrideValueBuilder) addBytes(count int) bool {
|
||||||
|
if count < 0 || int64(count) > maxSourceBytes-b.bytes {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
b.bytes += int64(count)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *overrideValueBuilder) enter(value reflect.Value, path Path) (func(), *Diagnostic) {
|
||||||
|
visit, ok := visitFor(value)
|
||||||
|
if !ok {
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
if b.active[visit] {
|
||||||
|
return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override values must not contain cycles")
|
||||||
|
}
|
||||||
|
b.active[visit] = true
|
||||||
|
return func() { delete(b.active, visit) }, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func overrideValueDiagnostic(code Code, path Path, message string) *Diagnostic {
|
||||||
|
return &Diagnostic{Code: code, Severity: SeverityError, Path: path.clone(), Message: message}
|
||||||
|
}
|
||||||
|
|
||||||
|
func overrideNumber(text string, location document.Location) *document.Node {
|
||||||
|
return &document.Node{Kind: document.Number, Text: text, Location: location}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -33,6 +33,21 @@ type parsedSource struct {
|
|||||||
bytes int64
|
bytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parserDiagnostics translates adapter issues into detached, ordered public records.
|
||||||
|
func parserDiagnostics(sourceID string, issues []yamladapter.Issue) []Diagnostic {
|
||||||
|
diagnostics := make([]Diagnostic, len(issues))
|
||||||
|
for index, issue := range issues {
|
||||||
|
diagnostics[index] = Diagnostic{
|
||||||
|
Code: Code(issue.Code), Severity: SeverityError, Message: issue.Message,
|
||||||
|
Primary: &Location{SourceID: sourceID, Line: issue.Line, Column: issue.Column},
|
||||||
|
}
|
||||||
|
for _, related := range issue.Related {
|
||||||
|
diagnostics[index].Related = append(diagnostics[index].Related, publicLocation(related))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return canonicalDiagnostics(diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
func parseSource(source Source, sourceID string, limits parseLimits) (parsedSource, []Diagnostic) {
|
func parseSource(source Source, sourceID string, limits parseLimits) (parsedSource, []Diagnostic) {
|
||||||
read, diagnostics := readSource(source, sourceID, limits.source)
|
read, diagnostics := readSource(source, sourceID, limits.source)
|
||||||
if len(diagnostics) != 0 || read.info == nil {
|
if len(diagnostics) != 0 || read.info == nil {
|
||||||
@@ -41,27 +56,7 @@ func parseSource(source Source, sourceID string, limits parseLimits) (parsedSour
|
|||||||
|
|
||||||
root, issues := yamladapter.Parse(bytes.NewReader(read.data), sourceID, limits.document)
|
root, issues := yamladapter.Parse(bytes.NewReader(read.data), sourceID, limits.document)
|
||||||
if len(issues) != 0 {
|
if len(issues) != 0 {
|
||||||
diagnostics := make([]Diagnostic, len(issues))
|
return parsedSource{source: read.info, bytes: int64(len(read.data))}, parserDiagnostics(sourceID, issues)
|
||||||
for i, issue := range issues {
|
|
||||||
diagnostics[i] = Diagnostic{
|
|
||||||
Code: Code(issue.Code),
|
|
||||||
Severity: SeverityError,
|
|
||||||
Message: issue.Message,
|
|
||||||
Primary: &Location{
|
|
||||||
SourceID: sourceID,
|
|
||||||
Line: issue.Line,
|
|
||||||
Column: issue.Column,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, related := range issue.Related {
|
|
||||||
diagnostics[i].Related = append(diagnostics[i].Related, Location{
|
|
||||||
SourceID: related.SourceID,
|
|
||||||
Line: related.Line,
|
|
||||||
Column: related.Column,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parsedSource{source: read.info, bytes: int64(len(read.data))}, canonicalDiagnostics(diagnostics)
|
|
||||||
}
|
}
|
||||||
return parsedSource{document: root, source: read.info, bytes: int64(len(read.data))}, nil
|
return parsedSource{document: root, source: read.info, bytes: int64(len(read.data))}, nil
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
148
internal/engine/path.go
Normal file
148
internal/engine/path.go
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Path identifies a configuration value using YAML field names. Its textual form
|
||||||
|
// is a JSON Pointer: the root is empty, ~ escapes as ~0, and / escapes as ~1.
|
||||||
|
// Paths are semantic rather than filesystem or URL paths: empty, dot, dot-dot,
|
||||||
|
// percent-containing, and numeric keys retain their literal meaning. Parsed
|
||||||
|
// numeric tokens are resolved only when traversal knows their containing type.
|
||||||
|
//
|
||||||
|
// Paths are immutable. The zero value is the root path.
|
||||||
|
type Path struct {
|
||||||
|
segments []pathSegment
|
||||||
|
invalid bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type pathSegmentKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
pathSegmentKey pathSegmentKind = iota
|
||||||
|
pathSegmentIndex
|
||||||
|
pathSegmentParsed
|
||||||
|
)
|
||||||
|
|
||||||
|
type pathSegment struct {
|
||||||
|
kind pathSegmentKind
|
||||||
|
key string
|
||||||
|
index int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys constructs a path of mapping keys. Keys with no arguments returns the
|
||||||
|
// root path.
|
||||||
|
func Keys(keys ...string) Path {
|
||||||
|
p := Path{}
|
||||||
|
for _, key := range keys {
|
||||||
|
p = p.Key(key)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePath parses a JSON Pointer. Numeric tokens remain unresolved until schema
|
||||||
|
// or document traversal determines whether the containing value is a mapping or
|
||||||
|
// sequence.
|
||||||
|
func ParsePath(pointer string) (Path, error) {
|
||||||
|
if pointer == "" {
|
||||||
|
return Path{}, nil
|
||||||
|
}
|
||||||
|
if pointer[0] != '/' {
|
||||||
|
return Path{}, errors.New("configkit: path must be empty or begin with slash")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(pointer[1:], "/")
|
||||||
|
segments := make([]pathSegment, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
key, ok := unescapePathToken(part)
|
||||||
|
if !ok {
|
||||||
|
return Path{}, errors.New("configkit: path contains an invalid tilde escape")
|
||||||
|
}
|
||||||
|
segments = append(segments, pathSegment{kind: pathSegmentParsed, key: key})
|
||||||
|
}
|
||||||
|
return Path{segments: segments}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key returns a new path with key appended as a mapping-key segment.
|
||||||
|
func (p Path) Key(key string) Path {
|
||||||
|
segments := clonePathSegments(p.segments, 1)
|
||||||
|
segments = append(segments, pathSegment{kind: pathSegmentKey, key: key})
|
||||||
|
return Path{segments: segments, invalid: p.invalid}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index returns a new path with index appended as a sequence-index segment.
|
||||||
|
// A negative index produces an invalid path that configuration operations reject.
|
||||||
|
func (p Path) Index(index int) Path {
|
||||||
|
segments := clonePathSegments(p.segments, 1)
|
||||||
|
segments = append(segments, pathSegment{kind: pathSegmentIndex, index: index})
|
||||||
|
return Path{segments: segments, invalid: p.invalid || index < 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the path's canonical JSON Pointer spelling.
|
||||||
|
func (p Path) String() string {
|
||||||
|
if len(p.segments) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for _, segment := range p.segments {
|
||||||
|
b.WriteByte('/')
|
||||||
|
if segment.kind == pathSegmentIndex {
|
||||||
|
b.WriteString(strconv.Itoa(segment.index))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString(escapePathToken(segment.key))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Path) clone() Path {
|
||||||
|
return Path{segments: clonePathSegments(p.segments, 0), invalid: p.invalid}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Path) valid() bool {
|
||||||
|
return !p.invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
func clonePathSegments(segments []pathSegment, extra int) []pathSegment {
|
||||||
|
if len(segments) == 0 && extra == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := make([]pathSegment, len(segments), len(segments)+extra)
|
||||||
|
copy(cloned, segments)
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapePathToken(token string) string {
|
||||||
|
token = strings.ReplaceAll(token, "~", "~0")
|
||||||
|
return strings.ReplaceAll(token, "/", "~1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func unescapePathToken(token string) (string, bool) {
|
||||||
|
if !strings.Contains(token, "~") {
|
||||||
|
return token, true
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for i := 0; i < len(token); i++ {
|
||||||
|
if token[i] != '~' {
|
||||||
|
b.WriteByte(token[i])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i+1 >= len(token) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
switch token[i] {
|
||||||
|
case '0':
|
||||||
|
b.WriteByte('~')
|
||||||
|
case '1':
|
||||||
|
b.WriteByte('/')
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String(), true
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
248
internal/engine/projection.go
Normal file
248
internal/engine/projection.go
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"encoding"
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
func projectConfiguration(schema *schemaDescriptor, value reflect.Value, limits projectionLimits) (*document.Node, *projectionIssue) {
|
||||||
|
if schema == nil || schema.root == nil || !value.IsValid() || value.Type() != schema.root.typeOf {
|
||||||
|
return nil, &projectionIssue{code: CodeSchemaUnsupportedType, message: "configuration value does not match its schema"}
|
||||||
|
}
|
||||||
|
operation := valueOperation{schema: schema, limits: limits, active: make(map[valueVisit]bool)}
|
||||||
|
return operation.project(value, schema.root, Path{}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor, path Path, depth int) (*document.Node, *projectionIssue) {
|
||||||
|
if issue := o.checkDepth(path, depth); issue != nil {
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
if descriptor.kind == descriptorPointer {
|
||||||
|
if value.IsNil() {
|
||||||
|
return o.node(document.Null, path, depth)
|
||||||
|
}
|
||||||
|
leave, issue := o.enter(value, path)
|
||||||
|
if issue != nil {
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
defer leave()
|
||||||
|
return o.project(value.Elem(), descriptor.element, path, depth)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch descriptor.kind {
|
||||||
|
case descriptorStruct:
|
||||||
|
result, issue := o.node(document.Mapping, path, depth)
|
||||||
|
if issue != nil {
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
result.Mapping = make([]document.Entry, 0, len(descriptor.fields))
|
||||||
|
seen := make(map[string]bool, len(descriptor.fields))
|
||||||
|
for _, member := range descriptor.members {
|
||||||
|
memberValue := value.Field(member.index)
|
||||||
|
if member.inline {
|
||||||
|
if isNilValue(memberValue) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inline, issue := o.project(memberValue, member.descriptor, path, depth)
|
||||||
|
if issue != nil {
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
if inline.Kind != document.Mapping {
|
||||||
|
return nil, &projectionIssue{code: CodeSchemaUnsupportedType, path: path.clone(), message: "inline value did not project to a mapping"}
|
||||||
|
}
|
||||||
|
o.nodes-- // The flattened container is not retained in the output tree.
|
||||||
|
for _, entry := range inline.Mapping {
|
||||||
|
if seen[entry.Key] {
|
||||||
|
return nil, &projectionIssue{code: CodeSchemaAmbiguousField, path: path.Key(entry.Key), message: "inline value conflicts with a declared field"}
|
||||||
|
}
|
||||||
|
seen[entry.Key] = true
|
||||||
|
result.Mapping = append(result.Mapping, entry)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
childPath := path.Key(member.name)
|
||||||
|
if seen[member.name] {
|
||||||
|
return nil, &projectionIssue{code: CodeSchemaAmbiguousField, path: childPath, message: "inline value conflicts with a declared field"}
|
||||||
|
}
|
||||||
|
if issue := o.charge(childPath); issue != nil { // mapping key
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
child, issue := o.project(memberValue, member.descriptor, childPath, depth+1)
|
||||||
|
if issue != nil {
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
seen[member.name] = true
|
||||||
|
result.Mapping = append(result.Mapping, document.Entry{Key: member.name, Value: child})
|
||||||
|
}
|
||||||
|
for _, entry := range result.Mapping {
|
||||||
|
field := descriptor.inlineMap
|
||||||
|
if index, ok := descriptor.fieldByName[entry.Key]; ok {
|
||||||
|
field = &descriptor.fields[index]
|
||||||
|
}
|
||||||
|
if field != nil && field.secret {
|
||||||
|
entry.Value.Sensitive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
case descriptorMap:
|
||||||
|
if value.IsNil() {
|
||||||
|
return o.node(document.Null, path, depth)
|
||||||
|
}
|
||||||
|
leave, issue := o.enter(value, path)
|
||||||
|
if issue != nil {
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
defer leave()
|
||||||
|
result, issue := o.node(document.Mapping, path, depth)
|
||||||
|
if issue != nil {
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
result.Mapping = make([]document.Entry, 0, value.Len())
|
||||||
|
keys := value.MapKeys()
|
||||||
|
slices.SortFunc(keys, func(left, right reflect.Value) int {
|
||||||
|
return cmp.Compare(left.String(), right.String())
|
||||||
|
})
|
||||||
|
for _, key := range keys {
|
||||||
|
childPath := path.Key(key.String())
|
||||||
|
if issue := o.charge(childPath); issue != nil {
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
child, issue := o.project(value.MapIndex(key), descriptor.element, childPath, depth+1)
|
||||||
|
if issue != nil {
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
result.Mapping = append(result.Mapping, document.Entry{Key: key.String(), Value: child})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
case descriptorSlice:
|
||||||
|
if value.IsNil() {
|
||||||
|
return o.node(document.Null, path, depth)
|
||||||
|
}
|
||||||
|
leave, issue := o.enter(value, path)
|
||||||
|
if issue != nil {
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
defer leave()
|
||||||
|
fallthrough
|
||||||
|
case descriptorArray:
|
||||||
|
result, issue := o.node(document.Sequence, path, depth)
|
||||||
|
if issue != nil {
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
result.Sequence = make([]*document.Node, 0, value.Len())
|
||||||
|
for i := 0; i < value.Len(); i++ {
|
||||||
|
childPath := path.Index(i)
|
||||||
|
child, issue := o.project(value.Index(i), descriptor.element, childPath, depth+1)
|
||||||
|
if issue != nil {
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
result.Sequence = append(result.Sequence, child)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
case descriptorDynamic:
|
||||||
|
if value.IsNil() {
|
||||||
|
return o.node(document.Null, path, depth)
|
||||||
|
}
|
||||||
|
concrete := value.Elem()
|
||||||
|
dynamic, issue := o.dynamicDescriptor(concrete.Type(), path)
|
||||||
|
if issue != nil {
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
return o.project(concrete, dynamic, path, depth)
|
||||||
|
case descriptorString:
|
||||||
|
node, issue := o.node(document.String, path, depth)
|
||||||
|
if issue == nil {
|
||||||
|
node.Text = value.String()
|
||||||
|
}
|
||||||
|
return node, issue
|
||||||
|
case descriptorBoolean:
|
||||||
|
node, issue := o.node(document.Boolean, path, depth)
|
||||||
|
if issue == nil {
|
||||||
|
node.Text = strconv.FormatBool(value.Bool())
|
||||||
|
}
|
||||||
|
return node, issue
|
||||||
|
case descriptorSigned:
|
||||||
|
node, issue := o.node(document.Number, path, depth)
|
||||||
|
if issue == nil {
|
||||||
|
node.Text = strconv.FormatInt(value.Int(), 10)
|
||||||
|
}
|
||||||
|
return node, issue
|
||||||
|
case descriptorUnsigned:
|
||||||
|
node, issue := o.node(document.Number, path, depth)
|
||||||
|
if issue == nil {
|
||||||
|
node.Text = strconv.FormatUint(value.Uint(), 10)
|
||||||
|
}
|
||||||
|
return node, issue
|
||||||
|
case descriptorFloat:
|
||||||
|
if math.IsNaN(value.Float()) || math.IsInf(value.Float(), 0) {
|
||||||
|
return nil, &projectionIssue{code: CodeSchemaNonFinite, path: path.clone(), message: "configuration float must be finite"}
|
||||||
|
}
|
||||||
|
node, issue := o.node(document.Number, path, depth)
|
||||||
|
if issue == nil {
|
||||||
|
node.Text = strconv.FormatFloat(value.Float(), 'g', -1, value.Type().Bits())
|
||||||
|
}
|
||||||
|
return node, issue
|
||||||
|
case descriptorDuration:
|
||||||
|
node, issue := o.node(document.String, path, depth)
|
||||||
|
if issue == nil {
|
||||||
|
node.Text = time.Duration(value.Int()).String()
|
||||||
|
}
|
||||||
|
return node, issue
|
||||||
|
case descriptorSecret:
|
||||||
|
node, issue := o.node(document.String, path, depth)
|
||||||
|
if issue == nil {
|
||||||
|
node.Text = o.schema.secrets.Read(value).Name
|
||||||
|
}
|
||||||
|
return node, issue
|
||||||
|
case descriptorText:
|
||||||
|
text, issue := marshalCanonicalText(value, path)
|
||||||
|
if issue != nil {
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
node, nodeIssue := o.node(document.String, path, depth)
|
||||||
|
if nodeIssue == nil {
|
||||||
|
node.Text = text
|
||||||
|
}
|
||||||
|
return node, nodeIssue
|
||||||
|
default:
|
||||||
|
return nil, &projectionIssue{code: CodeSchemaUnsupportedType, path: path.clone(), message: "configuration type is not supported"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *valueOperation) node(kind document.Kind, path Path, depth int) (*document.Node, *projectionIssue) {
|
||||||
|
if issue := o.checkDepth(path, depth); issue != nil {
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
if issue := o.charge(path); issue != nil {
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
return &document.Node{Kind: kind}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalCanonicalText(value reflect.Value, path Path) (string, *projectionIssue) {
|
||||||
|
var marshaler encoding.TextMarshaler
|
||||||
|
if value.CanInterface() {
|
||||||
|
marshaler, _ = value.Interface().(encoding.TextMarshaler)
|
||||||
|
}
|
||||||
|
if marshaler == nil {
|
||||||
|
addressable := reflect.New(value.Type())
|
||||||
|
addressable.Elem().Set(value)
|
||||||
|
marshaler, _ = addressable.Interface().(encoding.TextMarshaler)
|
||||||
|
}
|
||||||
|
if marshaler == nil {
|
||||||
|
return "", &projectionIssue{code: CodeSchemaCodec, path: path.clone(), message: "custom text scalar has no usable text marshaler"}
|
||||||
|
}
|
||||||
|
text, err := marshaler.MarshalText()
|
||||||
|
if err != nil || !utf8.Valid(text) {
|
||||||
|
return "", &projectionIssue{code: CodeSchemaCodec, path: path.clone(), message: "custom text scalar could not be projected"}
|
||||||
|
}
|
||||||
|
return string(text), nil
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -328,7 +328,7 @@ func TestCompareDocumentTreesTracksSemanticChanges(t *testing.T) {
|
|||||||
if issue != nil {
|
if issue != nil {
|
||||||
t.Fatal(issue.message)
|
t.Fatal(issue.message)
|
||||||
}
|
}
|
||||||
deltas := compareDocumentTrees(before, after)
|
deltas := compareConfigurationValues(before, after)
|
||||||
want := []struct {
|
want := []struct {
|
||||||
path string
|
path string
|
||||||
kind documentDeltaKind
|
kind documentDeltaKind
|
||||||
@@ -352,16 +352,16 @@ func TestCompareDocumentTreesTracksSemanticChanges(t *testing.T) {
|
|||||||
t.Fatal("sequence comparison created index correspondence")
|
t.Fatal("sequence comparison created index correspondence")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if equal := compareDocumentTrees(after, document.Clone(after)); len(equal) != 0 {
|
if equal := compareConfigurationValues(after, document.Clone(after)); len(equal) != 0 {
|
||||||
t.Fatalf("equal trees changed = %#v", equal)
|
t.Fatalf("equal trees changed = %#v", equal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
510
internal/engine/provenance_test.go
Normal file
510
internal/engine/provenance_test.go
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type layeredItem struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type layeredConfig struct {
|
||||||
|
Shared struct {
|
||||||
|
Equal string `yaml:"equal"`
|
||||||
|
Imported string `yaml:"imported"`
|
||||||
|
Overlay string `yaml:"overlay"`
|
||||||
|
} `yaml:"shared"`
|
||||||
|
Values []int `yaml:"values"`
|
||||||
|
Items map[string]layeredItem `yaml:"items"`
|
||||||
|
Value any `yaml:"value"`
|
||||||
|
Nullable *string `yaml:"nullable"`
|
||||||
|
Derived int `yaml:"derived"`
|
||||||
|
Empty map[string]string `yaml:"empty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredProvenanceAgreesWithResolvedSnapshot(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports: [{path: imported.yml}]
|
||||||
|
shared: {equal: same}
|
||||||
|
values: [1, 2]
|
||||||
|
items:
|
||||||
|
keep: {value: 'yes'}
|
||||||
|
remove: {value: 'no'}
|
||||||
|
value: {child: base}
|
||||||
|
nullable: base
|
||||||
|
empty: {}
|
||||||
|
`)
|
||||||
|
importPath := writeImportFile(t, directory, "imported.yml", "shared: {imported: base}\n")
|
||||||
|
overlayPath := writeImportFile(t, directory, "overlays/testing.yml", `shared: {overlay: selected}
|
||||||
|
values: [3, 4]
|
||||||
|
value: selected
|
||||||
|
nullable: null
|
||||||
|
empty: {}
|
||||||
|
`)
|
||||||
|
defaultNullable := "default"
|
||||||
|
definition := Definition[layeredConfig]{
|
||||||
|
Defaults: func() layeredConfig {
|
||||||
|
defaults := layeredConfig{
|
||||||
|
Values: []int{0, 0}, Value: map[string]any{"child": "default"},
|
||||||
|
Nullable: &defaultNullable, Empty: map[string]string{"retained": "default"},
|
||||||
|
}
|
||||||
|
defaults.Shared.Equal = "same"
|
||||||
|
return defaults
|
||||||
|
},
|
||||||
|
Resolve: func(configuration *layeredConfig, resolution *Resolution) error {
|
||||||
|
Normalize(resolution, Keys("values"), &configuration.Values, []int{5},
|
||||||
|
"bounded values", Keys("shared", "imported"), Keys("shared", "imported"))
|
||||||
|
Normalize(resolution, Keys("values"), &configuration.Values, []int{5},
|
||||||
|
"bounded values", Keys("shared", "imported"))
|
||||||
|
delete(configuration.Items, "remove")
|
||||||
|
resolution.Record(Keys("items"), ChangeNormalization, "removed disabled items")
|
||||||
|
Default(resolution, Keys("derived"), &configuration.Derived, 7,
|
||||||
|
"derived from equal", Keys("shared", "equal"), Keys("shared", "equal"))
|
||||||
|
resolution.Warning(Keys("values"), "values were bounded", Keys("shared", "imported"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := testLoad[layeredConfig](File(mainPath), Options[layeredConfig]{
|
||||||
|
Imports: true, Overlay: "testing", Definition: definition,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
var loadError *LoadError
|
||||||
|
_ = errors.As(err, &loadError)
|
||||||
|
t.Fatalf("%v: %#v", err, loadError.Diagnostics())
|
||||||
|
}
|
||||||
|
wantSources := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, importPath), canonicalTestPath(t, overlayPath)}
|
||||||
|
sources := result.Sources()
|
||||||
|
gotSources := make([]string, len(sources))
|
||||||
|
for index, source := range sources {
|
||||||
|
gotSources[index] = source.Path
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(gotSources, wantSources) || sources[2].Layer != LayerOverlay {
|
||||||
|
t.Fatalf("sources = %#v", sources)
|
||||||
|
}
|
||||||
|
if result.Config.Shared.Equal != "same" || result.Config.Shared.Imported != "base" || result.Config.Shared.Overlay != "selected" ||
|
||||||
|
!reflect.DeepEqual(result.Config.Values, []int{5}) || result.Config.Nullable != nil || result.Config.Derived != 7 ||
|
||||||
|
result.Config.Empty["retained"] != "default" {
|
||||||
|
t.Fatalf("configuration = %#v", result.Config)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := result.Metadata()
|
||||||
|
equal, _ := metadata.Lookup(Keys("shared", "equal"))
|
||||||
|
derived, _ := metadata.Lookup(Keys("derived"))
|
||||||
|
nullable, _ := metadata.Lookup(Keys("nullable"))
|
||||||
|
empty, _ := metadata.Lookup(Keys("empty"))
|
||||||
|
firstValue, _ := metadata.Lookup(Keys("values").Index(0))
|
||||||
|
if !equal.Explicit || equal.Origin.Layer != LayerBase || derived.Explicit || derived.Origin.Operation != ChangeDerivedDefault ||
|
||||||
|
!nullable.Explicit || !nullable.Null || nullable.Origin.Layer != LayerOverlay ||
|
||||||
|
!empty.Explicit || empty.Origin.Layer != LayerOverlay || len(empty.Origin.Locations) != 2 ||
|
||||||
|
!firstValue.Explicit || firstValue.Origin.Operation != ChangeNormalization {
|
||||||
|
t.Fatalf("metadata = equal %#v derived %#v nullable %#v empty %#v value %#v", equal, derived, nullable, empty, firstValue)
|
||||||
|
}
|
||||||
|
if _, ok := metadata.Lookup(Keys("value", "child")); ok {
|
||||||
|
t.Fatal("overlay-removed child remains effective")
|
||||||
|
}
|
||||||
|
if _, ok := metadata.Lookup(Keys("values").Index(1)); ok {
|
||||||
|
t.Fatal("resolution-removed sequence index remains effective")
|
||||||
|
}
|
||||||
|
if _, ok := metadata.Lookup(Keys("items", "remove", "value")); ok {
|
||||||
|
t.Fatal("resolution-removed mapping child remains effective")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertHistoryLayersAndPaths(t, metadata.History(Keys("values").Index(1)),
|
||||||
|
[]Layer{LayerDefault, LayerBase, LayerOverlay, LayerResolution},
|
||||||
|
[]string{"/values/1", "/values/1", "/values", "/values"})
|
||||||
|
assertHistoryLayersAndPaths(t, metadata.History(Keys("value", "child")),
|
||||||
|
[]Layer{LayerDefault, LayerBase, LayerOverlay},
|
||||||
|
[]string{"/value/child", "/value/child", "/value"})
|
||||||
|
removedHistory := metadata.History(Keys("items", "remove", "value"))
|
||||||
|
assertHistoryLayersAndPaths(t, removedHistory,
|
||||||
|
[]Layer{LayerBase, LayerResolution},
|
||||||
|
[]string{"/items/remove/value", "/items"})
|
||||||
|
if !reflect.DeepEqual(removedHistory[1].Reasons, []string{"removed disabled items"}) {
|
||||||
|
t.Fatalf("removed history = %#v", removedHistory)
|
||||||
|
}
|
||||||
|
valuesHistory := metadata.History(Keys("values").Index(0))
|
||||||
|
lastValue := valuesHistory[len(valuesHistory)-1]
|
||||||
|
if !reflect.DeepEqual(lastValue.Reasons, []string{"bounded values"}) || len(lastValue.Dependencies) != 1 || lastValue.Dependencies[0].String() != "/shared/imported" {
|
||||||
|
t.Fatalf("canonical resolution history = %#v", lastValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
schema, issue := testBuildSchema(reflect.TypeFor[layeredConfig]())
|
||||||
|
if issue != nil {
|
||||||
|
t.Fatal(issue)
|
||||||
|
}
|
||||||
|
projected, projectionIssue := projectConfiguration(schema, reflect.ValueOf(result.Config), defaultProjectionLimits())
|
||||||
|
if projectionIssue != nil || !configurationValuesEqual(projected, result.final) {
|
||||||
|
t.Fatalf("typed projection and private snapshot disagree: %#v", projectionIssue)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata.history[Keys("values").Index(0).String()][0].Origin.Layer = "mutated"
|
||||||
|
sources[1].ImportedFrom.SourceID = "mutated"
|
||||||
|
diagnostics := result.Diagnostics()
|
||||||
|
diagnostics[0].Related[0].SourceID = "mutated"
|
||||||
|
result.Config.Values[0] = 99
|
||||||
|
freshHistory := result.Metadata().History(Keys("values").Index(0))
|
||||||
|
if freshHistory[0].Origin.Layer == "mutated" || result.Sources()[1].ImportedFrom.SourceID == "mutated" ||
|
||||||
|
result.Diagnostics()[0].Related[0].SourceID == "mutated" {
|
||||||
|
t.Fatal("returned inspection records alias private state")
|
||||||
|
}
|
||||||
|
snapshotValue, ok := documentAtPath(result.final, Keys("values").Index(0))
|
||||||
|
if !ok || snapshotValue.Text != "5" {
|
||||||
|
t.Fatal("caller configuration mutation changed the private final snapshot")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectedOverlayLoadsRemainIndependentConcurrently(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", "values: [1, 2]\n")
|
||||||
|
writeImportFile(t, directory, "overlays/testing.yml", "values: [3, 4]\n")
|
||||||
|
options := Options[layeredConfig]{Overlay: "testing"}
|
||||||
|
|
||||||
|
const loads = 16
|
||||||
|
results := make(chan *testResult[layeredConfig], loads)
|
||||||
|
errors := make(chan error, loads)
|
||||||
|
var group sync.WaitGroup
|
||||||
|
for index := 0; index < loads; index++ {
|
||||||
|
group.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer group.Done()
|
||||||
|
result, err := testLoad[layeredConfig](File(mainPath), options)
|
||||||
|
if err != nil {
|
||||||
|
errors <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results <- result
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
group.Wait()
|
||||||
|
close(results)
|
||||||
|
close(errors)
|
||||||
|
for err := range errors {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var collected []*testResult[layeredConfig]
|
||||||
|
for result := range results {
|
||||||
|
collected = append(collected, result)
|
||||||
|
}
|
||||||
|
if len(collected) != loads {
|
||||||
|
t.Fatalf("completed loads = %d", len(collected))
|
||||||
|
}
|
||||||
|
collected[0].Config.Values[0] = 99
|
||||||
|
collected[0].metadata.fields[Keys("values").String()] = FieldInfo{}
|
||||||
|
for _, result := range collected[1:] {
|
||||||
|
if !reflect.DeepEqual(result.Config.Values, []int{3, 4}) {
|
||||||
|
t.Fatalf("concurrent result contaminated = %#v", result.Config.Values)
|
||||||
|
}
|
||||||
|
if info, ok := result.Metadata().Lookup(Keys("values")); !ok || info.Origin.Layer != LayerOverlay {
|
||||||
|
t.Fatalf("concurrent metadata contaminated = %#v, %v", info, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolutionInputKeepsLayeredOriginsAndSources(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports: [{path: first.yml}, {path: second.yml}]
|
||||||
|
main: main
|
||||||
|
equal: same
|
||||||
|
cli_equal: same
|
||||||
|
composite: {main: main}
|
||||||
|
`)
|
||||||
|
firstPath := writeImportFile(t, directory, "first.yml", `imported: imported
|
||||||
|
sequence: [first, second]
|
||||||
|
composite: {first: first}
|
||||||
|
`)
|
||||||
|
secondPath := writeImportFile(t, directory, "second.yml", "composite: {second: second}\n")
|
||||||
|
overlayPath := writeImportFile(t, directory, "overlays/testing.yml", "equal: same\noverlay: overlay\n")
|
||||||
|
compositeSources := map[string]bool{
|
||||||
|
canonicalTestPath(t, mainPath): true,
|
||||||
|
canonicalTestPath(t, firstPath): true,
|
||||||
|
canonicalTestPath(t, secondPath): true,
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Main string `yaml:"main"`
|
||||||
|
Imported string `yaml:"imported"`
|
||||||
|
Sequence []string `yaml:"sequence"`
|
||||||
|
Composite map[string]string `yaml:"composite"`
|
||||||
|
Equal string `yaml:"equal"`
|
||||||
|
Overlay string `yaml:"overlay"`
|
||||||
|
CLIEqual string `yaml:"cli_equal"`
|
||||||
|
}
|
||||||
|
var callbackErr error
|
||||||
|
result, err := testLoad[config](File(mainPath), Options[config]{
|
||||||
|
Imports: true, Overlay: "testing", Overrides: []Override{{Path: Keys("cli_equal"), Value: "same", Flag: "--cli-equal"}},
|
||||||
|
Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
||||||
|
check := func(ok bool, message string) {
|
||||||
|
if !ok && callbackErr == nil {
|
||||||
|
callbackErr = errors.New(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fileOwner := func(path Path, want string) FieldInfo {
|
||||||
|
info, found := resolution.InputInfo(path)
|
||||||
|
check(found && len(info.Origin.Locations) == 1, "scalar has no exact origin")
|
||||||
|
if !found || len(info.Origin.Locations) != 1 {
|
||||||
|
return FieldInfo{}
|
||||||
|
}
|
||||||
|
source, exists := resolution.Source(info.Origin.Locations[0].SourceID)
|
||||||
|
check(exists && source.Path == want, "scalar source does not match its declaration")
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
fileOwner(Keys("main"), canonicalTestPath(t, mainPath))
|
||||||
|
fileOwner(Keys("imported"), canonicalTestPath(t, firstPath))
|
||||||
|
fileOwner(Keys("sequence").Index(1), canonicalTestPath(t, firstPath))
|
||||||
|
overlay := fileOwner(Keys("equal"), canonicalTestPath(t, overlayPath))
|
||||||
|
fileOwner(Keys("overlay"), canonicalTestPath(t, overlayPath))
|
||||||
|
check(overlay.Origin.Layer == LayerOverlay, "overlay replacement retained base ownership")
|
||||||
|
cli, found := resolution.InputInfo(Keys("cli_equal"))
|
||||||
|
check(found && cli.Origin.Layer == LayerCLI && len(cli.Origin.Locations) == 1, "CLI replacement lost ownership")
|
||||||
|
if found && len(cli.Origin.Locations) == 1 {
|
||||||
|
source, exists := resolution.Source(cli.Origin.Locations[0].SourceID)
|
||||||
|
check(exists && source.Layer == LayerCLI && source.Path == "", "CLI source has a file path or is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
composite, found := resolution.InputInfo(Keys("composite"))
|
||||||
|
check(found && len(composite.Origin.Locations) == 3, "composite mapping did not retain every contributor")
|
||||||
|
for _, location := range composite.Origin.Locations {
|
||||||
|
source, exists := resolution.Source(location.SourceID)
|
||||||
|
check(exists && compositeSources[source.Path], "composite location has no expected source record")
|
||||||
|
delete(compositeSources, source.Path)
|
||||||
|
}
|
||||||
|
check(len(compositeSources) == 0, "composite sources are incomplete")
|
||||||
|
|
||||||
|
imported, _ := resolution.InputInfo(Keys("imported"))
|
||||||
|
importedSourceID := imported.Origin.Locations[0].SourceID
|
||||||
|
imported.Origin.Locations[0].SourceID = "mutated"
|
||||||
|
importSource, _ := resolution.Source(importedSourceID)
|
||||||
|
check(importSource.ImportedFrom != nil, "import source is missing its import location")
|
||||||
|
if importSource.ImportedFrom != nil {
|
||||||
|
importSource.ImportedFrom.SourceID = "mutated"
|
||||||
|
}
|
||||||
|
again, _ := resolution.InputInfo(Keys("imported"))
|
||||||
|
againSource, _ := resolution.Source(importedSourceID)
|
||||||
|
check(again.Origin.Locations[0].SourceID == importedSourceID, "input record is aliased")
|
||||||
|
check(againSource.ImportedFrom != nil && againSource.ImportedFrom.SourceID == "source-1", "source record is aliased")
|
||||||
|
return callbackErr
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if callbackErr != nil {
|
||||||
|
t.Fatal(callbackErr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config.Main != "main" || result.Config.Imported != "imported" || !reflect.DeepEqual(result.Config.Sequence, []string{"first", "second"}) ||
|
||||||
|
result.Config.Equal != "same" || result.Config.Overlay != "overlay" || result.Config.CLIEqual != "same" {
|
||||||
|
t.Fatalf("configuration = %#v", result.Config)
|
||||||
|
}
|
||||||
|
|
||||||
|
resultField, _ := result.Metadata().Lookup(Keys("imported"))
|
||||||
|
resultSource := result.Sources()[1]
|
||||||
|
if resultField.Origin.Locations[0].SourceID != "source-2" || resultSource.ImportedFrom == nil || resultSource.ImportedFrom.SourceID != "source-1" {
|
||||||
|
t.Fatal("callback record mutation changed the completed result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolutionInputUsesEntryShapesAcrossTypedAndDynamicValues(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type Embedded struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Embedded `yaml:",inline"`
|
||||||
|
Mappings map[string]string `yaml:"mappings"`
|
||||||
|
Array [2]string `yaml:"array"`
|
||||||
|
Dynamic any `yaml:"dynamic"`
|
||||||
|
}
|
||||||
|
var callbackErr 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) {
|
||||||
|
_, found := resolution.InputInfo(path)
|
||||||
|
if found != want && callbackErr == nil {
|
||||||
|
callbackErr = errors.New("entry shape lookup disagrees with its document container")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsedMapping, _ := ParsePath("/mappings/-0")
|
||||||
|
escapedMapping, _ := ParsePath("/mappings/a~1b~0")
|
||||||
|
check(Keys("name"), true)
|
||||||
|
check(parsedMapping, true)
|
||||||
|
check(escapedMapping, true)
|
||||||
|
check(Keys("mappings").Index(0), false)
|
||||||
|
check(Keys("array").Index(1), true)
|
||||||
|
check(Keys("array", "1"), false)
|
||||||
|
check(Keys("array").Index(2), false)
|
||||||
|
check(Keys("dynamic", "nested").Index(0), true)
|
||||||
|
check(Keys("dynamic", "nested", "0"), false)
|
||||||
|
negative, _ := ParsePath("/array/-1")
|
||||||
|
check(negative, false)
|
||||||
|
return callbackErr
|
||||||
|
}}})
|
||||||
|
if callbackErr != nil {
|
||||||
|
t.Fatal(callbackErr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolutionInputDistinguishesNonFileRootsAndImportedFiles(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
importPath := writeImportFile(t, directory, "fragment.yml", "imported: file\n")
|
||||||
|
type config struct {
|
||||||
|
Root string `yaml:"root"`
|
||||||
|
Imported string `yaml:"imported"`
|
||||||
|
Default string `yaml:"default"`
|
||||||
|
}
|
||||||
|
input := "configkit: {imports: [{path: fragment.yml}]}\nroot: memory\n"
|
||||||
|
context := &FilesystemContext{BaseDir: directory, Root: directory}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
source func() Source
|
||||||
|
}{
|
||||||
|
{name: "bytes", source: func() Source { return Bytes("memory://config.yml", []byte(input)) }},
|
||||||
|
{name: "reader", source: func() Source { return Reader("memory://config.yml", strings.NewReader(input)) }},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
var callbackErr error
|
||||||
|
_, err := testLoad[config](test.source(), Options[config]{Imports: true, Filesystem: context, Definition: Definition[config]{
|
||||||
|
Defaults: func() config { return config{Default: "retained"} },
|
||||||
|
Resolve: func(_ *config, resolution *Resolution) error {
|
||||||
|
root, _ := resolution.InputInfo(Keys("root"))
|
||||||
|
imported, _ := resolution.InputInfo(Keys("imported"))
|
||||||
|
defaulted, _ := resolution.InputInfo(Keys("default"))
|
||||||
|
rootSource, rootFound := resolution.Source(root.Origin.Locations[0].SourceID)
|
||||||
|
importSource, importFound := resolution.Source(imported.Origin.Locations[0].SourceID)
|
||||||
|
if !rootFound || rootSource.Path != "" || !importFound || importSource.Path != canonicalTestPath(t, importPath) || defaulted.Explicit {
|
||||||
|
callbackErr = errors.New("non-file root or imported file provenance is wrong")
|
||||||
|
}
|
||||||
|
if _, found := resolution.Source("unknown"); found {
|
||||||
|
callbackErr = errors.New("unknown source was found")
|
||||||
|
}
|
||||||
|
return callbackErr
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
if callbackErr != nil {
|
||||||
|
t.Fatal(callbackErr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var callbackErr error
|
||||||
|
_, err := testLoad[config](Bytes("memory://standalone.yml", []byte("root: memory\n")), Options[config]{Definition: Definition[config]{
|
||||||
|
Defaults: func() config { return config{Default: "retained"} },
|
||||||
|
Resolve: func(_ *config, resolution *Resolution) error {
|
||||||
|
root, rootFound := resolution.InputInfo(Keys("root"))
|
||||||
|
defaulted, defaultFound := resolution.InputInfo(Keys("default"))
|
||||||
|
source, sourceFound := resolution.Source(root.Origin.Locations[0].SourceID)
|
||||||
|
if !rootFound || !defaultFound || !sourceFound || source.Path != "" || defaulted.Explicit {
|
||||||
|
callbackErr = errors.New("standalone non-file provenance is wrong")
|
||||||
|
}
|
||||||
|
return callbackErr
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
if callbackErr != nil {
|
||||||
|
t.Fatal(callbackErr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolutionInputRemainsFixedAcrossRecordedMutations(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
Existing string `yaml:"existing"`
|
||||||
|
Values map[string]string `yaml:"values"`
|
||||||
|
}
|
||||||
|
var callbackErr 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"))
|
||||||
|
_, destinationFound := resolution.InputInfo(Keys("values", "destination"))
|
||||||
|
existing, existingFound := resolution.InputInfo(Keys("existing"))
|
||||||
|
Normalize(resolution, Keys("existing"), &value.Existing, "normalized", "normalized existing value")
|
||||||
|
value.Values["destination"] = value.Values["original"]
|
||||||
|
delete(value.Values, "original")
|
||||||
|
resolution.Record(Keys("values"), ChangeNormalization, "moved configured value")
|
||||||
|
againOriginal, againOriginalFound := resolution.InputInfo(Keys("values", "original"))
|
||||||
|
againDestination, againDestinationFound := resolution.InputInfo(Keys("values", "destination"))
|
||||||
|
againExisting, againExistingFound := resolution.InputInfo(Keys("existing"))
|
||||||
|
if !originalFound || destinationFound || !existingFound || !againOriginalFound || againDestinationFound || !againExistingFound ||
|
||||||
|
!reflect.DeepEqual(original, againOriginal) || !reflect.DeepEqual(existing, againExisting) || !reflect.DeepEqual(againDestination, FieldInfo{}) {
|
||||||
|
callbackErr = errors.New("entry view changed during resolution")
|
||||||
|
}
|
||||||
|
return callbackErr
|
||||||
|
}}})
|
||||||
|
if callbackErr != nil {
|
||||||
|
t.Fatal(callbackErr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config.Existing != "normalized" || result.Config.Values["destination"] != "move" {
|
||||||
|
t.Fatalf("configuration = %#v", result.Config)
|
||||||
|
}
|
||||||
|
if _, found := result.Metadata().Lookup(Keys("values", "original")); found {
|
||||||
|
t.Fatal("final metadata retained a removed path")
|
||||||
|
}
|
||||||
|
destination, found := result.Metadata().Lookup(Keys("values", "destination"))
|
||||||
|
existing, existingFound := result.Metadata().Lookup(Keys("existing"))
|
||||||
|
if !found || destination.Origin.Layer != LayerResolution || !existingFound || existing.Origin.Layer != LayerResolution {
|
||||||
|
t.Fatalf("final metadata = destination %#v existing %#v", destination, existing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolutionInputQueriesRemainIndependentAcrossLoads(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
const loads = 16
|
||||||
|
failures := make(chan error, loads)
|
||||||
|
var group sync.WaitGroup
|
||||||
|
for index := 0; index < loads; index++ {
|
||||||
|
group.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer group.Done()
|
||||||
|
_, 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"))
|
||||||
|
source, sourceFound := resolution.Source(info.Origin.Locations[0].SourceID)
|
||||||
|
if !found || !sourceFound || source.ID != "source-1" {
|
||||||
|
return errors.New("independent entry lookup failed")
|
||||||
|
}
|
||||||
|
info.Origin.Locations[0].SourceID = "mutated"
|
||||||
|
source.Label = "mutated"
|
||||||
|
return nil
|
||||||
|
}}})
|
||||||
|
if err != nil {
|
||||||
|
failures <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
group.Wait()
|
||||||
|
close(failures)
|
||||||
|
for err := range failures {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertHistoryLayersAndPaths(t *testing.T, history []Change, layers []Layer, paths []string) {
|
||||||
|
t.Helper()
|
||||||
|
if len(history) != len(layers) || len(history) != len(paths) {
|
||||||
|
t.Fatalf("history = %#v", history)
|
||||||
|
}
|
||||||
|
for index, change := range history {
|
||||||
|
if change.Origin.Layer != layers[index] || change.Path.String() != paths[index] {
|
||||||
|
t.Fatalf("history[%d] = %#v", index, change)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
286
internal/engine/records.go
Normal file
286
internal/engine/records.go
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
// Layer identifies the precedence layer responsible for a configuration value.
|
||||||
|
type Layer string
|
||||||
|
|
||||||
|
const (
|
||||||
|
LayerDefault Layer = "default"
|
||||||
|
LayerBase Layer = "base"
|
||||||
|
LayerOverlay Layer = "overlay"
|
||||||
|
LayerCLI Layer = "cli"
|
||||||
|
LayerResolution Layer = "resolution"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChangeKind identifies a recorded operation. The zero value is
|
||||||
|
// used for input and default origins.
|
||||||
|
type ChangeKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ChangeDecoding records the built-in transition to canonical typed values.
|
||||||
|
// It is inspection metadata, not an operation accepted by Resolution.Record.
|
||||||
|
ChangeDecoding ChangeKind = "decoding"
|
||||||
|
ChangeDerivedDefault ChangeKind = "derived_default"
|
||||||
|
ChangeNormalization ChangeKind = "normalization"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Severity classifies a diagnostic.
|
||||||
|
type Severity string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SeverityError Severity = "error"
|
||||||
|
SeverityWarning Severity = "warning"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Code is a stable, machine-readable diagnostic classification.
|
||||||
|
type Code string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CodeSourceInvalid Code = "source.invalid"
|
||||||
|
CodeSourceMissing Code = "source.missing"
|
||||||
|
CodeSourcePermission Code = "source.permission"
|
||||||
|
CodeSourceRead Code = "source.read"
|
||||||
|
CodeSourceNotRegular Code = "source.not_regular"
|
||||||
|
CodeSourceLimitExceeded Code = "source.limit_exceeded"
|
||||||
|
CodeSourceReaderUsed Code = "source.reader_used"
|
||||||
|
|
||||||
|
CodeYAMLInvalid Code = "yaml.invalid"
|
||||||
|
CodeYAMLEmpty Code = "yaml.empty"
|
||||||
|
CodeYAMLMultipleDocuments Code = "yaml.multiple_documents"
|
||||||
|
CodeYAMLRootType Code = "yaml.root_type"
|
||||||
|
CodeYAMLDuplicateKey Code = "yaml.duplicate_key"
|
||||||
|
CodeYAMLTag Code = "yaml.tag"
|
||||||
|
CodeYAMLAnchor Code = "yaml.anchor"
|
||||||
|
CodeYAMLAlias Code = "yaml.alias"
|
||||||
|
CodeYAMLMergeKey Code = "yaml.merge_key"
|
||||||
|
CodeYAMLScalar Code = "yaml.scalar"
|
||||||
|
CodeYAMLDepthLimit Code = "yaml.depth_limit"
|
||||||
|
CodeYAMLNodeLimit Code = "yaml.node_limit"
|
||||||
|
|
||||||
|
CodeSchemaRoot Code = "schema.root"
|
||||||
|
CodeSchemaUnsupportedType Code = "schema.unsupported_type"
|
||||||
|
CodeSchemaAmbiguousField Code = "schema.ambiguous_field"
|
||||||
|
CodeSchemaTag Code = "schema.tag"
|
||||||
|
CodeSchemaTextCodec Code = "schema.text_codec"
|
||||||
|
CodeSchemaInline Code = "schema.inline"
|
||||||
|
CodeSchemaCycle Code = "schema.cycle"
|
||||||
|
CodeSchemaProjectionLimit Code = "schema.projection_limit"
|
||||||
|
CodeSchemaCodec Code = "schema.codec"
|
||||||
|
CodeSchemaNonFinite Code = "schema.non_finite"
|
||||||
|
CodeSchemaDynamicType Code = "schema.dynamic_type"
|
||||||
|
|
||||||
|
CodeDecodeType Code = "decode.type"
|
||||||
|
CodeDecodeNull Code = "decode.null"
|
||||||
|
CodeDecodeRange Code = "decode.range"
|
||||||
|
CodeDecodeDuration Code = "decode.duration"
|
||||||
|
CodeDecodeText Code = "decode.text"
|
||||||
|
CodeDecodeUnknownField Code = "decode.unknown_field"
|
||||||
|
CodeDecodeArrayLength Code = "decode.array_length"
|
||||||
|
|
||||||
|
CodeOptionsInvalid Code = "options.invalid"
|
||||||
|
CodeOptionsUnavailable Code = "options.unavailable"
|
||||||
|
|
||||||
|
CodeImportDisabled Code = "import.disabled"
|
||||||
|
CodeImportInvalid Code = "import.invalid"
|
||||||
|
CodeImportMissing Code = "import.missing"
|
||||||
|
CodeImportPermission Code = "import.permission"
|
||||||
|
CodeImportRead Code = "import.read"
|
||||||
|
CodeImportOutsideRoot Code = "import.outside_root"
|
||||||
|
CodeImportCycle Code = "import.cycle"
|
||||||
|
CodeImportRepeated Code = "import.repeated"
|
||||||
|
CodeImportLimitExceeded Code = "import.limit_exceeded"
|
||||||
|
CodeImportVersion Code = "import.version"
|
||||||
|
|
||||||
|
CodeOverlayInvalid Code = "overlay.invalid"
|
||||||
|
CodeOverlayMissing Code = "overlay.missing"
|
||||||
|
CodeOverlayOutsideRoot Code = "overlay.outside_root"
|
||||||
|
CodeOverlayRepeated Code = "overlay.repeated"
|
||||||
|
CodeOverlayDirective Code = "overlay.directive"
|
||||||
|
CodeOverlayVersion Code = "overlay.version"
|
||||||
|
CodeOverlayLimitExceeded Code = "overlay.limit_exceeded"
|
||||||
|
|
||||||
|
CodeComposeInvalid Code = "compose.invalid"
|
||||||
|
CodeComposeConflict Code = "compose.conflict"
|
||||||
|
|
||||||
|
CodeVersionType Code = "version.type"
|
||||||
|
CodeVersionError Code = "version.error"
|
||||||
|
CodeVersionWarning Code = "version.warning"
|
||||||
|
CodeVersionHookFailed Code = "version.hook_failed"
|
||||||
|
|
||||||
|
CodeResolveError Code = "resolve.error"
|
||||||
|
CodeResolveWarning Code = "resolve.warning"
|
||||||
|
CodeResolveHookFailed Code = "resolve.hook_failed"
|
||||||
|
CodeResolveInvalidPath Code = "resolve.invalid_path"
|
||||||
|
CodeResolveInvalidKind Code = "resolve.invalid_kind"
|
||||||
|
CodeResolveInvalidRecord Code = "resolve.invalid_record"
|
||||||
|
CodeResolveInvalidTarget Code = "resolve.invalid_target"
|
||||||
|
CodeResolveInvalidValue Code = "resolve.invalid_value"
|
||||||
|
CodeResolveUnrecordedChange Code = "resolve.unrecorded_change"
|
||||||
|
|
||||||
|
CodeValidateError Code = "validate.error"
|
||||||
|
CodeValidateWarning Code = "validate.warning"
|
||||||
|
CodeValidateInvalidPath Code = "validate.invalid_path"
|
||||||
|
CodeValidateMutation Code = "validate.mutation"
|
||||||
|
|
||||||
|
CodeSecretsInvalidPath Code = "secrets.invalid_path"
|
||||||
|
CodeSecretsMissing Code = "secrets.missing"
|
||||||
|
CodeSecretsPermission Code = "secrets.permission"
|
||||||
|
CodeSecretsRead Code = "secrets.read"
|
||||||
|
CodeSecretsNotRegular Code = "secrets.not_regular"
|
||||||
|
CodeSecretsInvalidName Code = "secrets.invalid_name"
|
||||||
|
CodeSecretsLimitExceeded Code = "secrets.limit_exceeded"
|
||||||
|
CodeSecretsInvalidValue Code = "secrets.invalid_value"
|
||||||
|
CodeSecretsApplyFailed Code = "secrets.apply_failed"
|
||||||
|
|
||||||
|
CodeSecretRequired Code = "secret.required"
|
||||||
|
CodeSecretInvalidName Code = "secret.invalid_name"
|
||||||
|
CodeSecretUnset Code = "secret.unset"
|
||||||
|
CodeSecretEmpty Code = "secret.empty"
|
||||||
|
|
||||||
|
CodeOverrideInvalidPath Code = "override.invalid_path"
|
||||||
|
CodeOverrideInvalidLabel Code = "override.invalid_label"
|
||||||
|
CodeOverrideInvalidValue Code = "override.invalid_value"
|
||||||
|
CodeOverrideDuplicate Code = "override.duplicate"
|
||||||
|
CodeOverrideOverlap Code = "override.overlap"
|
||||||
|
CodeOverrideDirective Code = "override.directive"
|
||||||
|
CodeOverrideVersion Code = "override.version"
|
||||||
|
CodeOverrideLimitExceeded Code = "override.limit_exceeded"
|
||||||
|
|
||||||
|
CodeExplainLimitExceeded Code = "explain.limit_exceeded"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Location identifies a position in a configuration source. Zero line and column
|
||||||
|
// values mean that coordinates are unavailable or synthetic.
|
||||||
|
type Location struct {
|
||||||
|
SourceID string
|
||||||
|
Line int
|
||||||
|
Column int
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceInfo describes a source discovered during a load. Path is a canonical
|
||||||
|
// local path when one exists. ImportedFrom identifies the declaration that
|
||||||
|
// included this source.
|
||||||
|
type SourceInfo struct {
|
||||||
|
ID string
|
||||||
|
Label string
|
||||||
|
Path string
|
||||||
|
Layer Layer
|
||||||
|
ImportedFrom *Location
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagnostic is a safe structured problem report. Message and source labels must
|
||||||
|
// never contain configuration values or other secrets.
|
||||||
|
type Diagnostic struct {
|
||||||
|
Code Code
|
||||||
|
Severity Severity
|
||||||
|
Path Path
|
||||||
|
Message string
|
||||||
|
Primary *Location
|
||||||
|
Related []Location
|
||||||
|
}
|
||||||
|
|
||||||
|
// Origin describes where an effective definition came from. Multiple locations
|
||||||
|
// represent a composite origin; resolution origins are synthetic and identify their
|
||||||
|
// operation instead of a file location.
|
||||||
|
type Origin struct {
|
||||||
|
Layer Layer
|
||||||
|
Locations []Location
|
||||||
|
Operation ChangeKind
|
||||||
|
}
|
||||||
|
|
||||||
|
// FieldInfo describes an effective configuration path.
|
||||||
|
type FieldInfo struct {
|
||||||
|
Explicit bool
|
||||||
|
Null bool
|
||||||
|
Origin Origin
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change describes a definition or application-resolution operation. Path can name
|
||||||
|
// an ancestor operation affecting the queried descendant. Historical values are
|
||||||
|
// intentionally not exposed by this record.
|
||||||
|
type Change struct {
|
||||||
|
Path Path
|
||||||
|
Origin Origin
|
||||||
|
Previous []Origin
|
||||||
|
Reasons []string
|
||||||
|
Dependencies []Path
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneLocation(location *Location) *Location {
|
||||||
|
if location == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := *location
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneLocations(locations []Location) []Location {
|
||||||
|
if locations == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return append([]Location(nil), locations...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneSourceInfo(source SourceInfo) SourceInfo {
|
||||||
|
source.ImportedFrom = cloneLocation(source.ImportedFrom)
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneSourceInfos(sources []SourceInfo) []SourceInfo {
|
||||||
|
if sources == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := make([]SourceInfo, len(sources))
|
||||||
|
for i, source := range sources {
|
||||||
|
cloned[i] = cloneSourceInfo(source)
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneOrigin(origin Origin) Origin {
|
||||||
|
origin.Locations = cloneLocations(origin.Locations)
|
||||||
|
return origin
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneFieldInfo(info FieldInfo) FieldInfo {
|
||||||
|
info.Origin = cloneOrigin(info.Origin)
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneChange(change Change) Change {
|
||||||
|
change.Path = change.Path.clone()
|
||||||
|
change.Origin = cloneOrigin(change.Origin)
|
||||||
|
if change.Previous != nil {
|
||||||
|
previous := make([]Origin, len(change.Previous))
|
||||||
|
for i, origin := range change.Previous {
|
||||||
|
previous[i] = cloneOrigin(origin)
|
||||||
|
}
|
||||||
|
change.Previous = previous
|
||||||
|
}
|
||||||
|
change.Reasons = append([]string(nil), change.Reasons...)
|
||||||
|
if change.Dependencies != nil {
|
||||||
|
dependencies := make([]Path, len(change.Dependencies))
|
||||||
|
for i, path := range change.Dependencies {
|
||||||
|
dependencies[i] = path.clone()
|
||||||
|
}
|
||||||
|
change.Dependencies = dependencies
|
||||||
|
}
|
||||||
|
return change
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneDiagnostic(diagnostic Diagnostic) Diagnostic {
|
||||||
|
diagnostic.Path = diagnostic.Path.clone()
|
||||||
|
diagnostic.Primary = cloneLocation(diagnostic.Primary)
|
||||||
|
diagnostic.Related = cloneLocations(diagnostic.Related)
|
||||||
|
return diagnostic
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneDiagnostics(diagnostics []Diagnostic) []Diagnostic {
|
||||||
|
if diagnostics == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := make([]Diagnostic, len(diagnostics))
|
||||||
|
for i, diagnostic := range diagnostics {
|
||||||
|
cloned[i] = cloneDiagnostic(diagnostic)
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -14,7 +14,7 @@ var errEffectiveUnavailable = errors.New("configkit: effective configuration is
|
|||||||
// WriteEffective writes deterministic redacted YAML from the frozen resolved
|
// WriteEffective writes deterministic redacted YAML from the frozen resolved
|
||||||
// snapshot. The output is intended for inspection and is not promised reloadable.
|
// snapshot. The output is intended for inspection and is not promised reloadable.
|
||||||
// Writer errors, including short writes, are returned and may leave partial output.
|
// Writer errors, including short writes, are returned and may leave partial output.
|
||||||
func (r *Result[T]) WriteEffective(output io.Writer) error {
|
func (r *Inspection) WriteEffective(output io.Writer) error {
|
||||||
if r == nil || r.final == nil || output == nil {
|
if r == nil || r.final == nil || output == nil {
|
||||||
return errEffectiveUnavailable
|
return errEffectiveUnavailable
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -71,7 +71,7 @@ empty_sequence: []
|
|||||||
labels: {hidden: DYNAMIC_CANARY_6a12, public: visible}
|
labels: {hidden: DYNAMIC_CANARY_6a12, public: visible}
|
||||||
sequence: [second, first]
|
sequence: [second, first]
|
||||||
`)
|
`)
|
||||||
writeImportFile(t, directory, "profiles/testing.yml", "dynamic: selected\n")
|
writeImportFile(t, directory, "overlays/testing.yml", "dynamic: selected\n")
|
||||||
definition := Definition[renderConfig]{
|
definition := Definition[renderConfig]{
|
||||||
Secret: func(path Path) bool {
|
Secret: func(path Path) bool {
|
||||||
return path.String() == "/labels/hidden" || path.String() == "/dynamic/retired"
|
return path.String() == "/labels/hidden" || path.String() == "/dynamic/retired"
|
||||||
@@ -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, Overlay: "testing"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -139,7 +139,7 @@ sequence: [second, first]
|
|||||||
if err := result.WriteEffective(shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
|
if err := result.WriteEffective(shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
|
||||||
t.Fatalf("short writer error = %v", err)
|
t.Fatalf("short writer error = %v", err)
|
||||||
}
|
}
|
||||||
var nilResult *Result[renderConfig]
|
var nilResult *testResult[renderConfig]
|
||||||
if err := nilResult.WriteEffective(io.Discard); !errors.Is(err, errEffectiveUnavailable) {
|
if err := nilResult.WriteEffective(io.Discard); !errors.Is(err, errEffectiveUnavailable) {
|
||||||
t.Fatalf("nil result error = %v", err)
|
t.Fatalf("nil result error = %v", 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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -15,6 +15,7 @@ type resolutionState struct {
|
|||||||
schema *schemaDescriptor
|
schema *schemaDescriptor
|
||||||
presence map[string]inputPresence
|
presence map[string]inputPresence
|
||||||
document *document.Node
|
document *document.Node
|
||||||
|
input resolutionInput
|
||||||
records map[string]*resolutionRecord
|
records map[string]*resolutionRecord
|
||||||
diagnostics []Diagnostic
|
diagnostics []Diagnostic
|
||||||
}
|
}
|
||||||
@@ -50,6 +51,27 @@ func (r *Resolution) IsExplicit(path Path) bool {
|
|||||||
return explicit
|
return explicit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InputInfo returns detached entry-state metadata for path. The entry state is
|
||||||
|
// the decoded configuration, including retained static defaults, immediately
|
||||||
|
// before resolution starts. It is fixed while resolution records mutate the
|
||||||
|
// working configuration; unknown or shape-mismatched paths return false.
|
||||||
|
//
|
||||||
|
// InputInfo is valid only during the resolution callback. See the configuration
|
||||||
|
// contract for source and provenance semantics.
|
||||||
|
func (r *Resolution) InputInfo(path Path) (FieldInfo, bool) {
|
||||||
|
return r.activeState().input.lookup(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source returns a detached record for a source acquired by this load. Empty or
|
||||||
|
// unknown IDs return false and do not perform input, filesystem, or credential
|
||||||
|
// access. A source path is present only when that acquired source has one.
|
||||||
|
//
|
||||||
|
// Source is valid only during the resolution callback. See the configuration
|
||||||
|
// contract for source and provenance semantics.
|
||||||
|
func (r *Resolution) Source(id string) (SourceInfo, bool) {
|
||||||
|
return r.activeState().input.source(id)
|
||||||
|
}
|
||||||
|
|
||||||
// Record declares the intent for a resolver mutation. A container path covers
|
// Record declares the intent for a resolver mutation. A container path covers
|
||||||
// mutations to its descendants.
|
// mutations to its descendants.
|
||||||
func (r *Resolution) Record(path Path, kind ChangeKind, reason string, dependencies ...Path) {
|
func (r *Resolution) Record(path Path, kind ChangeKind, reason string, dependencies ...Path) {
|
||||||
@@ -221,9 +243,10 @@ func (state *resolutionState) addDiagnostic(code Code, path Path, message string
|
|||||||
state.diagnostics = append(state.diagnostics, diagnostic)
|
state.diagnostics = append(state.diagnostics, diagnostic)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error, schema *schemaDescriptor, decoded decodedConfiguration, input *document.Node) resolutionResult {
|
func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error, schema *schemaDescriptor, decoded decodedConfiguration, input *document.Node, entry resolutionInput) resolutionResult {
|
||||||
state := &resolutionState{
|
state := &resolutionState{
|
||||||
active: true, schema: schema, presence: decoded.presence, document: input,
|
active: true, schema: schema, presence: decoded.presence, document: input,
|
||||||
|
input: entry,
|
||||||
records: make(map[string]*resolutionRecord),
|
records: make(map[string]*resolutionRecord),
|
||||||
}
|
}
|
||||||
context := &Resolution{state: state}
|
context := &Resolution{state: state}
|
||||||
@@ -241,7 +264,7 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
|
|||||||
state.addDiagnostic(CodeResolveInvalidValue, issue.path, "application resolution produced an invalid configuration value")
|
state.addDiagnostic(CodeResolveInvalidValue, issue.path, "application resolution produced an invalid configuration value")
|
||||||
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
|
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
|
||||||
}
|
}
|
||||||
deltas := compareDocumentTrees(decoded.final, after)
|
deltas := compareConfigurationValues(decoded.final, after)
|
||||||
ordered := orderedResolutionRecords(state.records)
|
ordered := orderedResolutionRecords(state.records)
|
||||||
for _, delta := range deltas {
|
for _, delta := range deltas {
|
||||||
if coveringResolutionRecord(ordered, delta.path) == nil {
|
if coveringResolutionRecord(ordered, delta.path) == nil {
|
||||||
@@ -249,16 +272,15 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, record := range ordered {
|
for _, record := range ordered {
|
||||||
beforeNode, beforePresent := documentAtPath(decoded.final, record.path)
|
_, beforePresent := documentAtPath(decoded.final, record.path)
|
||||||
afterNode, afterPresent := documentAtPath(after, record.path)
|
afterNode, afterPresent := documentAtPath(after, record.path)
|
||||||
if !beforePresent && !afterPresent {
|
if !beforePresent && !afterPresent {
|
||||||
state.addDiagnostic(CodeResolveInvalidRecord, record.path, "resolution record does not identify an existing or removed value")
|
state.addDiagnostic(CodeResolveInvalidRecord, record.path, "resolution record does not identify an existing or removed value")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if record.helperExpected != nil && (!afterPresent || !documentNodesEqual(record.helperExpected, afterNode)) {
|
if record.helperExpected != nil && (!afterPresent || !configurationValuesEqual(record.helperExpected, afterNode)) {
|
||||||
state.addDiagnostic(CodeResolveInvalidTarget, record.path, "resolution helper assignment does not match its declared configuration path")
|
state.addDiagnostic(CodeResolveInvalidTarget, record.path, "resolution helper assignment does not match its declared configuration path")
|
||||||
}
|
}
|
||||||
_ = beforeNode
|
|
||||||
}
|
}
|
||||||
if hasErrors(state.diagnostics) {
|
if hasErrors(state.diagnostics) {
|
||||||
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
|
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
|
||||||
@@ -343,17 +365,3 @@ func clonePaths(paths []Path) []Path {
|
|||||||
}
|
}
|
||||||
return cloned
|
return cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSnapshot {
|
|
||||||
if snapshots == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cloned := make([]resolutionSnapshot, len(snapshots))
|
|
||||||
for index, snapshot := range snapshots {
|
|
||||||
cloned[index] = resolutionSnapshot{
|
|
||||||
change: cloneChange(snapshot.change),
|
|
||||||
before: document.Clone(snapshot.before), after: document.Clone(snapshot.after),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cloned
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -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") {
|
||||||
@@ -280,6 +280,83 @@ func TestResolutionRejectsInvalidProjectedValuesAndSuppressesReturnedErrors(t *t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolutionEntryQueriesExposeDetachedLoadInput(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
Declared string `yaml:"declared"`
|
||||||
|
Default string `yaml:"default"`
|
||||||
|
Optional *string `yaml:"optional"`
|
||||||
|
}
|
||||||
|
filename := writeImportFile(t, t.TempDir(), "config.yml", "declared: file\noptional: null\n")
|
||||||
|
var savedField FieldInfo
|
||||||
|
var savedSource SourceInfo
|
||||||
|
result, err := testLoad[config](File(filename), Options[config]{Definition: Definition[config]{
|
||||||
|
Defaults: func() config { return config{Default: "retained"} },
|
||||||
|
Resolve: func(_ *config, resolution *Resolution) error {
|
||||||
|
for _, path := range []Path{Keys("declared"), Keys("default"), Keys("optional")} {
|
||||||
|
info, found := resolution.InputInfo(path)
|
||||||
|
if !found || info.Explicit != resolution.IsExplicit(path) {
|
||||||
|
return errors.New("entry metadata does not agree with explicitness")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defaultInfo, _ := resolution.InputInfo(Keys("default"))
|
||||||
|
optionalInfo, _ := resolution.InputInfo(Keys("optional"))
|
||||||
|
if defaultInfo.Explicit || defaultInfo.Origin.Layer != LayerDefault || !optionalInfo.Explicit || !optionalInfo.Null {
|
||||||
|
return errors.New("entry metadata lost default or null state")
|
||||||
|
}
|
||||||
|
if _, found := resolution.InputInfo(Keys("missing")); found {
|
||||||
|
return errors.New("missing entry path was found")
|
||||||
|
}
|
||||||
|
declared, _ := resolution.InputInfo(Keys("declared"))
|
||||||
|
if len(declared.Origin.Locations) != 1 {
|
||||||
|
return errors.New("declared source location is missing")
|
||||||
|
}
|
||||||
|
source, found := resolution.Source(declared.Origin.Locations[0].SourceID)
|
||||||
|
if !found || source.Path != canonicalTestPath(t, filename) || source.ID != "source-1" {
|
||||||
|
return errors.New("declared source record is missing")
|
||||||
|
}
|
||||||
|
if _, found := resolution.Source(""); found {
|
||||||
|
return errors.New("empty source identity was found")
|
||||||
|
}
|
||||||
|
if _, found := resolution.Source("unknown"); found {
|
||||||
|
return errors.New("unknown source identity was found")
|
||||||
|
}
|
||||||
|
savedField, savedSource = declared, source
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config.Declared != "file" || result.Config.Default != "retained" || result.Config.Optional != nil {
|
||||||
|
t.Fatalf("configuration = %#v", result.Config)
|
||||||
|
}
|
||||||
|
savedField.Origin.Locations[0].SourceID = "mutated"
|
||||||
|
savedSource.Label = "mutated"
|
||||||
|
field, _ := result.Metadata().Lookup(Keys("declared"))
|
||||||
|
if field.Origin.Locations[0].SourceID != "source-1" || result.Sources()[0].Label == "mutated" {
|
||||||
|
t.Fatal("saved resolution records alias a completed result")
|
||||||
|
}
|
||||||
|
|
||||||
|
noSource, err := testLoad[config](NoSource(), Options[config]{Definition: Definition[config]{
|
||||||
|
Defaults: func() config { return config{Default: "retained"} },
|
||||||
|
Resolve: func(_ *config, resolution *Resolution) error {
|
||||||
|
info, found := resolution.InputInfo(Keys("default"))
|
||||||
|
if !found || info.Explicit || resolution.IsExplicit(Keys("default")) {
|
||||||
|
return errors.New("defaults-only entry metadata is wrong")
|
||||||
|
}
|
||||||
|
if _, found := resolution.Source("source-1"); found {
|
||||||
|
return errors.New("defaults-only load invented a source")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
if err != nil || noSource.Config.Default != "retained" {
|
||||||
|
t.Fatalf("defaults-only load = %#v, %v", noSource, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolutionContextPanicsAfterCallback(t *testing.T) {
|
func TestResolutionContextPanicsAfterCallback(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -287,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 {
|
||||||
@@ -301,6 +378,45 @@ func TestResolutionContextPanicsAfterCallback(t *testing.T) {
|
|||||||
retained.IsExplicit(Keys("value"))
|
retained.IsExplicit(Keys("value"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolutionEntryQueriesRequireAnActiveCallback(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
Value int `yaml:"value"`
|
||||||
|
}
|
||||||
|
requireClosed := func(t *testing.T, call func()) {
|
||||||
|
t.Helper()
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != closedCallbackContextMessage {
|
||||||
|
t.Fatalf("panic = %#v", recovered)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
call()
|
||||||
|
}
|
||||||
|
requireClosed(t, func() { (*Resolution)(nil).InputInfo(Keys("value")) })
|
||||||
|
requireClosed(t, func() { (&Resolution{}).Source("source-1") })
|
||||||
|
|
||||||
|
var successful *Resolution
|
||||||
|
if _, err := testLoad(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
||||||
|
successful = resolution
|
||||||
|
return nil
|
||||||
|
}}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
requireClosed(t, func() { successful.InputInfo(Keys("value")) })
|
||||||
|
requireClosed(t, func() { successful.Source("source-1") })
|
||||||
|
|
||||||
|
var failed *Resolution
|
||||||
|
if _, err := testLoad(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
||||||
|
failed = resolution
|
||||||
|
return errors.New("resolver failed")
|
||||||
|
}}}); err == nil {
|
||||||
|
t.Fatal("failed resolver succeeded")
|
||||||
|
}
|
||||||
|
requireClosed(t, func() { failed.InputInfo(Keys("value")) })
|
||||||
|
requireClosed(t, func() { failed.Source("source-1") })
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidationContextPanicsAfterVersionCallback(t *testing.T) {
|
func TestValidationContextPanicsAfterVersionCallback(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -308,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
|
||||||
@@ -324,7 +440,7 @@ func TestValidationContextPanicsAfterVersionCallback(t *testing.T) {
|
|||||||
retained.Warning(Keys("version"), "too late")
|
retained.Warning(Keys("version"), "too late")
|
||||||
}
|
}
|
||||||
|
|
||||||
func assertLoadDiagnostic[T any](t *testing.T, result *Result[T], err error, code Code) *LoadError {
|
func assertLoadDiagnostic[T any](t *testing.T, result *testResult[T], err error, code Code) *LoadError {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
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)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package configkit
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding"
|
"encoding"
|
||||||
@@ -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)
|
||||||
@@ -434,5 +464,5 @@ func schemaIndex(segment pathSegment) (int, bool) {
|
|||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
index, err := strconv.Atoi(segment.key)
|
index, err := strconv.Atoi(segment.key)
|
||||||
return index, err == nil && index >= 0
|
return index, err == nil && index >= 0 && strconv.Itoa(index) == segment.key
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user